A merge takes a reference on a segment's pooled reader, then opens a fresh copy with the latest deletes. If that open throws, the reference is never given back, so Lucene never closes that reader's files.
- IndexWriter keeps at most one
SegmentReaderper segment in a pool (on by default), opened on first use, for example by an NRT reader or a merge. Each user takes a reference (incRef) and gives it back (decRef); a reader closes its files when its count reaches 0. - A doc-values update (soft deletes are one too) writes new files for the segment: a field-infos file of the new generation and doc-values files such as
_0_1_Lucene90_0.dvd, which the segment's readers then open. - A merge needs each segment with its latest deletes. If the pooled reader is older than a delete, the merge opens a fresh copy that shares the old reader's core and re-reads the update's field-infos file.
getReaderForMerge never gives back its +1 when the fresh reader fails to open; the segment's 4 files stay open. Select the still to open it at full size. Generated with AI, checked against the code.- Index 2 documents, "a" and "b", with a numeric doc-values field, update the field of "a" with
updateNumericDocValue, and open and close an NRT reader. The writer now keeps segment_0's reader in its pool. - Delete "b". The pooled reader does not have that delete yet, so a merge of
_0must first open a fresh copy of it; because_0has a doc-values update, that copy re-reads the update's field-infos file from disk. - Run
forceMerge(1)while that file open fails with anIOException. You expect the merge to fail and, onceIndexWriter.close()has returned, no index file to be open. - Instead 4 files stay open: the pooled reader's
_0.cfsand its three_0_1_Lucene90_0doc-values files. The merge took a reference on the pooled reader (+1) and gives it back only after the fresh copy opens; when the open throws, nothing gives it back, so the reader's count never reaches 0 and Lucene never closes it. - The same leak happens on a plain
commit()with a default config, through merge-on-commit: the commit throws, the writer stays healthy, commits again and closes normally, and 4 files stay open. Search results and deletions stay correct.
Applications that use doc-values updates or soft deletes and hit an I/O error (too many open files, a storage or permission error, a throwing Directory) exactly while a merge refreshes such a segment. Each failure leaks one segment reader: its files stay open or mapped after close, and keep their disk space once deleted. Results stay correct.
Mechanism, terms and numbers612 words
When a merge starts, ReadersAndUpdates.getReaderForMerge takes a reference on the segment's pooled SegmentReader. If that reader's live docs are stale, it opens a fresh reader that shares the old one's core and has the latest deletes, and releases the old reader only after that succeeds. For a segment with doc-values updates (soft deletes are doc-values updates too) the fresh reader re-reads the update's field-infos file, so an I/O error can make it throw; then nobody releases the reference. The merge never received the reader, so it cannot release it, and closing the writer drops only the pool's own reference: Lucene never closes that reader's files. In the 2-document repro the segment's compound file and three doc-values files stay open after close; with a default IndexWriterConfig a plain commit() (merge-on-commit) leaks the corresponding 4 files of that segment while the writer keeps working. Results, deletions and later commits stay correct. Introduced in 4.6.0 by LUCENE-5189; every release executed from 4.6.0 to 10.6.0 and main leaks. The fix is one hunk: release the reference when the refresh throws.
// ReadersAndUpdates.getReaderForMerge, condensed
SegmentReader reader = getReader(context); // :1095 +1 on the pooled reader ("Ref for caller", :195-196)
if (pendingDeletes.needsRefresh(reader) || ...) { // :1096 pooled live docs are stale
reader = createNewReaderWithLatestLiveDocs(reader); // :1100 may throw: nothing releases the +1
}
// ReadersAndUpdates.createNewReaderWithLatestLiveDocs, condensed
SegmentReader newReader = new SegmentReader(info, reader, liveDocs, ...); // :1070-1077 shares the core; does I/O
try {
pendingDeletes.onNewReader(newReader, info); // :1079
reader.decRef(); // :1080 the caller's ref is released only here
} catch (Throwable t) {
newReader.decRef(); // :1082 cleans up the NEW reader only
throw t;
}- Every merge of the writer's own segments gets its readers through
getReaderForMerge, from the merge-reader factories inIndexWriter.java:4083-4088(merge-on-commit and merge-on-refresh) and:5484-5491(all other merges), viaOneMerge.initMergeReaders(MergePolicy.java:465-481);addIndexes(CodecReader...)merges use the caller's readers instead (MergePolicy.java:256). ReadersAndUpdates.java:1095takes a reference on the pooled reader. When its live docs are stale (:1096-1097),:1100callscreateNewReaderWithLatestLiveDocs.- The core-sharing
SegmentReaderconstructor (SegmentReader.java:128-162) callsinitFieldInfos()andinitDocValuesProducer()(:156-157). For a segment with field updates, these read the new generation's field-infos file (:196-203) and set up the doc-values producers of the update generations (:186-187; shared with the pooled reader, opened only if not open yet), so the constructor does I/O and can throw. On failure it closes only what it took (doClose(),:159) and rethrows. - So
:1080never runs and neither method releases the reference from:1095.initMergeReaderskeeps only the readers the factory returned (MergePolicy.java:474-479), so the merge's cleanup (IndexWriter.closeMergeReaders,:5394-5425) never sees this one. - When the writer closes (
IndexWriter.close(); in the minimal test already the rollback that the tragic failure runs insideforceMerge,IndexWriter.java:2677),ReaderPool.dropAll(ReaderPool.java:314-341) callsReadersAndUpdates.dropReaders, which releases only the pool's own reference (ReadersAndUpdates.java:213-225). The count stays at 1, so the reader's core (the compound file) and its doc-values producers are never closed. - Without field updates the constructor reuses the core's field infos and the already-open doc-values producer (
SegmentReader.java:197-198,:188-190), so plain segments cannot hit this. A second, smaller leak sits on the same path: the two factories take aReadersAndUpdatesreference withgetPooledInstance(sci, true)(IndexWriter.java:4085,:5486) and do not release it whengetReaderForMergethrows. With fix A alone no file stays open; the only observed effect of this second leak is that the segment'sReadersAndUpdatesreference count stays at 1 after close (0 with fix B; read by reflection in a probe of the same test).
Exposure and likelihood389 words
- Who. Applications that update doc-values fields (
updateNumericDocValue,updateBinaryDocValue,updateDocValues) or use soft deletes (once applying them has given the segment a field-update generation,SegmentCommitInfo.hasFieldUpdates), and get an I/O error at the one moment a merge opens the fresh reader of such a segment whose pooled reader has stale live docs. That step only opens and reads files Lucene already wrote, so the errors are those of opening a file: too many open files, a transient storage error, a permission error, or a customDirectorythat throws. - Default vs opt-in. Doc-values updates and soft deletes are opt-in APIs; the rest is default. Reader pooling is on by default (
IndexWriterConfig.DEFAULT_READER_POOLING = true), and merge-on-commit and merge-on-refresh run with a default config (DEFAULT_MAX_FULL_FLUSH_MERGE_WAIT_MILLIS = 500). On those two paths the failure is not tragic and the writer keeps working (merge-on-commit executed on main645d4fe14a20with assertions on and off; merge-on-refresh executed onea57360912edand 10.5.2). Natural and forced merges reach it too; there the failure is tragic and the writer closes itself, as designed, with the same leak. - Scale. None needed: 2 documents. Each failure leaks one reference on the segment's pooled reader. Repeated failures on the same pooled reader leak one more reference each (by code reading), but open no new file or mapping (4 failed attempts in the probe's refresh arms).
- What stays open (stock main
ea57360912ed, real file system; byte counts are the probe's estimates from the sizes of the held files): onMMapDirectory, the 64-bit default, no descriptor is held, but the 4 leaked files, deleted by a later merge, were still mapped afterIndexWriter.close(),Directory.close()and fiveSystem.gc()calls: 1,069,056 bytes in the refresh arm, 2,138,112 in the force-merge arm. While a deleted file stays mapped, the OS does not free its disk space. The force-merge arm holds twice as much because, by code reading, the per-segment shared arena also keeps later mappings of the same segment's files alive (bounded by its 64 permits). OnNIOFSDirectory, 4 descriptors on the deleted files stayed open afterIndexWriter.close()andDirectory.close()(1,056,459 bytes in the refresh arm, 1,056,460 in the force-merge arm) and were released only after the fiveSystem.gc()calls. - Not affected. Search results, deletions and later commits: no deleted document became visible in any run.
One JUnit test, public API plus the test framework's MockDirectoryWrapper: 2 documents, one doc-values update, one NRT reader opened and closed, one delete, then forceMerge(1) with one injected IOException while the merge opens the fresh reader. It checks that no file is open after IndexWriter.close().
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.index;
import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.tests.store.MockDirectoryWrapper;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
public class TestMergeReaderRefreshFailureLeak extends LuceneTestCase {
public void testFailedMergeReaderRefreshLeavesFilesOpen() throws Exception {
MockDirectoryWrapper dir = new MockDirectoryWrapper(random(), new ByteBuffersDirectory());
dir.failOn(
new MockDirectoryWrapper.Failure() {
@Override
public void eval(MockDirectoryWrapper d) throws IOException {
if (callStackContainsAnyOf("getReaderForMerge")) {
throw new IOException("injected");
}
}
});
IndexWriter w =
new IndexWriter(
dir,
new IndexWriterConfig()
.setCodec(TestUtil.getDefaultCodec())
.setMergeScheduler(new SerialMergeScheduler()));
for (String id : new String[] {"a", "b"}) {
Document doc = new Document();
doc.add(new StringField("id", id, Field.Store.NO));
doc.add(new NumericDocValuesField("n", 1));
w.addDocument(doc);
}
w.updateNumericDocValue(new Term("id", "a"), "n", 2);
DirectoryReader.open(w).close();
w.deleteDocuments(new Term("id", "b"));
expectThrows(IOException.class, () -> w.forceMerge(1));
w.close();
try {
dir.close();
} catch (RuntimeException e) {
fail("expected no open files after IndexWriter.close(), but " + e.getMessage());
}
}
}
cp $REPRO/TestMergeReaderRefreshFailureLeak.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestMergeReaderRefreshFailureLeak -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false
- Expected
forceMerge(1)throws the injectedIOException, and afterIndexWriter.close()no index file is open.- Observed
MockDirectoryWrapper.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}" (assertions on and off).
More files, environment, full output269 words
One test class, one method, single thread. MockDirectoryWrapper checks the failure on every file operation while getReaderForMerge is on the stack; on this path it fires once, on the open of the fresh reader's field-infos file _0_1.fnm. The merge that fails is the full-flush merge started by forceMerge's own flush (IndexWriter.java:2248, :4379), which also applies the buffered delete. The default codec is pinned so the file names are stable, and SerialMergeScheduler brings the merge exception to the test thread. The fault predicate names only the method, no package-private type; the class sits in org.apache.lucene.index by test convention. It fails on 20 of 20 random seeds and passes on 20 of 20 with fix A alone (and with A and B together). The only random input the test passes is MockDirectoryWrapper(random(), ...), which varies yields and throttling (its random I/O failure rates are 0 by default); no run changed the outcome.
This same test is the suggested regression test: it fails on stock main and passes with fix A and with fix B.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails with assertions on and off; 10.6.0: fails; with fix A or B: passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (some lines cut)
TestMergeReaderRefreshFailureLeak > testFailedMergeReaderRefreshLeavesFilesOpen FAILED
java.lang.AssertionError: expected no open files after IndexWriter.close(), but 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:B818C0E66CBDA5B3]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.apache.lucene.index.TestMergeReaderRefreshFailureLeak.testFailedMergeReaderRefreshLeavesFilesOpen(TestMergeReaderRefreshFailureLeak.java:62)
1 test completed, 1 failed
# the same failure with -Ptests.asserts=false; with fix A or B: 1 test completed, 0 failedIndexWriter.close()"Closes all open resources";IndexReader.incRef: always pair it withdecRef, "otherwise the reader may never be closed".- The surrounding code cleans up on failure: the refresh closes the new reader, and the merge keeps partial readers to close them.
- No tragedy needed: on the default merge-on-commit path the writer stays healthy and closes normally, yet the files stay open.
The full argument311 words
- Contracts.
IndexWriter.close(): "Closes all open resources and releases the write lock." (IndexWriter.java:1342).IndexReader.incRef(): "Be sure to always call a correspondingdecRef, in a finally clause; otherwise the reader may never be closed." (IndexReader.java:166-168). - The surrounding code is exception-safe.
createNewReaderWithLatestLiveDocsreleases the new reader whenonNewReaderfails (ReadersAndUpdates.java:1081-1083);initMergeReaderskeeps partly acquired readers "to close them in the case of an exception" (MergePolicy.java:476-479); the constructor closes what it opened (SegmentReader.java:158-160). Only the caller's reference on the old reader is missed. - Readers may outlive the writer only when the application holds them.
ReaderPool.dropAll: "it is allowed that these decRefs do not actually close the SRs; this happens when a near real-time reader is kept open after the IndexWriter instance is closed" (ReaderPool.java:327-330). The repro closes its NRT reader first. - Upstream precedent: tests close a
MockDirectoryWrapperafter injected failures.TestIndexWriterOutOfFileDescriptorsinjectsIOExceptions on file opens, lets merges fail (tragically, when they hit the merge scheduler), rolls the writer back and closes aMockDirectoryWrapper, which fails on any open file; it uses no doc-values updates and does not target this path, so it is precedent, not a test of it.TestIndexWriterOnErroruses doc-values updates, but its failures throw anErrorwith a 1-in-3000 draw at each failure check whileIndexWriteris on the stack.CrankyCodecnever fails the reads this step does (its field-infos, doc-values and compound-file readers delegate without injecting). - History. The same class of leak was fixed as a bug before: LUCENE-5656 "Fix rare fd leak in SegmentReader when multiple docvalues fields have been updated with IndexWriter.updateXXXDocValue and one hits exception" and LUCENE-5262 "Fixed file handle leaks when multiple attempts to open an NRT reader hit exceptions". In the history and prior-art material reviewed, no commit, comment, test, CHANGES or MIGRATE entry accepts this one.
The devil's-advocate check140 words
Strongest case: the minimal test is a tragic merge failure, and the IndexWriter javadoc says that after an Error or a disaster it closes itself "in case any internal state (buffered documents, deletions, reference counts) were corrupted" (IndexWriter.java:162-165); on #12228 a maintainer said that on a tragedy it is unsafe to delete files.
Why it fails: (1) the leak needs no tragedy: on the default merge-on-commit path getTragicException() is null, the writer commits again and closes normally, and the corresponding 4 files of that segment stay open (executed on 645d4fe14a20); (2) the javadoc note is about an Error or a checkpoint disaster and does not say files may stay open, and TestIndexWriterOutOfFileDescriptors expects every file closed after tragic merge failures from IOExceptions; (3) #12228 is about deleting files, while the fix only releases a reference.
Recommended A. Release the pooled reader in getReaderForMerge when the refresh throws
In getReaderForMerge, release the pooled reader when createNewReaderWithLatestLiveDocs throws (+8/-1, one hunk). The repro passes with assertions on and off, and the whole core module passes (8,898 tests, 0 failures). Option B also releases a second, smaller reference in IndexWriter's two merge-reader factories.
// beware of zombies:
assert pendingDeletes.getLiveDocs() != null;
- reader = createNewReaderWithLatestLiveDocs(reader);
+ final SegmentReader pooled = reader;
+ try {
+ reader = createNewReaderWithLatestLiveDocs(pooled);
+ } catch (Throwable t) {
+ // createNewReaderWithLatestLiveDocs releases our reference only on success
+ pooled.decRef();
+ throw t;
+ }
}All options and trade-offs2 options · 675 words
Both options were run through gradle in a scratch worktree of 645d4fe14a20. A is the root-cause fix and alone removes the leak in every repro; B adds the release of the ReadersAndUpdates reference that the merge-reader factories take, whose leak leaves no file open (its only observed effect is an internal reference count of 1 after close). Fixing it inside createNewReaderWithLatestLiveDocs instead would be wrong: its other caller, swapNewReaderWithLatestLiveDocs (ReadersAndUpdates.java:1088-1090), passes the pool's own reader and keeps it in the reader field when the call throws, so a decRef there would drop the pool's reference while the field still points at the reader.
A. Release the pooled reader in getReaderForMerge when the refresh throws (validated by the checks) (recommended)
In ReadersAndUpdates.getReaderForMerge (ReadersAndUpdates.java:1100), +8/-1.
// beware of zombies:
assert pendingDeletes.getLiveDocs() != null;
- reader = createNewReaderWithLatestLiveDocs(reader);
+ final SegmentReader pooled = reader;
+ try {
+ reader = createNewReaderWithLatestLiveDocs(pooled);
+ } catch (Throwable t) {
+ // createNewReaderWithLatestLiveDocs releases our reference only on success
+ pooled.decRef();
+ throw t;
+ }
}Trade-offs
- The smallest change, at the one caller that owns the extra reference; the success path is unchanged.
- The
decRefcannot close the reader: the pool still holds its own reference, and the method holds theReadersAndUpdateslock. - The same hunk applies, with line offsets, to the 10.6.0 and 9.12.3 sources (checked with
git apply --check, and again by the fact check withpatch --dry-run: offsets +62 and -309; not built there). - Scope: A covers a failure anywhere in
createNewReaderWithLatestLiveDocs. A failure after the refresh, inreaderConsumer.accept(ReadersAndUpdates.java:1105), is not covered; the two current consumers callIndexFileDeleter.incRef(Collection), which declares noIOException, so that is not a demonstrated path.
What the checks validated
- Stock
645d4fe14a20: the minimal test fails through gradle with assertions on and off (4 open files). - With A: the minimal test passes with assertions on and off, and on 20 of 20 random seeds (fact check).
- With A, assertions on, seed
DEADBEEF: the test plusTestIndexWriterOutOfFileDescriptors,TestIndexWriterOnError,TestIndexWriterExceptions,TestIndexWriterExceptions2,TestIndexWriterReader,TestIndexWriterMergePolicy,TestNumericDocValuesUpdates,TestBinaryDocValuesUpdates,TestMixedDocValuesUpdates,TestSoftDeletesRetentionMergePolicy,TestPendingSoftDeletesandTestReaderPoolpass (13 suites, 195 tests, 0 failures, 10 skipped); the whole:lucene:core:testpasses (749 suites, 8,898 tests, 0 failures, 322 skipped). checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestof:lucene:corepass with A and the test in place.- Earlier, on
ea57360912ed: A alone made the independent reproducer's, the devil's advocate's and the adjudication's tests pass, with the injection still firing at the same site.
B. A, plus release the ReadersAndUpdates reference in the two merge-reader factories (validated by the checks)
In IndexWriter.java:4084-4088 and :5485-5491, +24/-4: when getReaderForMerge throws, call release(rld) for the getPooledInstance(sci, true) reference, which closeMergeReaders releases (IndexWriter.java:5413) only for readers the merge received.
sci -> {
final ReadersAndUpdates rld = getPooledInstance(sci, true);
- return rld.getReaderForMerge(
- context, mr -> deleter.incRef(mr.reader.getSegmentInfo().files()));
+ boolean gotReader = false;
+ try {
+ MergePolicy.MergeReader mr =
+ rld.getReaderForMerge(
+ context, r -> deleter.incRef(r.reader.getSegmentInfo().files()));
+ gotReader = true;
+ return mr;
+ } finally {
+ if (gotReader == false) {
+ release(rld); // the merge never got this reader, so it will not release it
+ }
+ }
});
(the same change in the mergeMiddle factory, inside its synchronized block)Trade-offs
- Also closes the second, smaller leak, which leaves no file open (its only observed effect is the segment's
ReadersAndUpdatescount staying at 1 after close). - Open PR #16571 ("Let a merge write more than one segment") moves the
mergeMiddlefactory that this part also changes.
What the checks validated
- With B: the minimal test passes with assertions on and off, and the same 13 suites pass (195 tests, 0 failures, 10 skipped); the whole core module was not run with B.
- Earlier, on
ea57360912ed: the finder's version of B with the three other fixes of its hunt passed 15 upstream test classes (380 tests per seed, seedsDEADBEEFand1234ABCD, 0 failures).
Engines that always use soft deletes and merge on flush make the segment state common, but the trigger is still an I/O error at this one step (read only, nothing executed downstream).
Fault injection at every I/O point: each index-level IndexWriter operation was run while an IOException was thrown at each distinct file operation, then the test checked that no file stayed open. 68 runs left files open; reading the code found the missing release, and a JUnit test confirmed it.
The method in full130 words
Method: bounded-exhaustive exception-point enumeration on the real jar (the study's semi-formal tier). Each index-level operation was run against an executable reference of its documented outcome while an IOException was injected at every distinct I/O throw point, one-shot and sticky, followed by a check that the MockDirectoryWrapper closes with no open file.
- 68 runs failed that last check ("cannot close: there are still N open files") after a correctly classified outcome; the first was in a soft-deletes configuration with pooled readers, failing in
SegmentReader.initFieldInfosundercreateNewReaderWithLatestLiveDocsandgetReaderForMerge. - The root cause was then located by reading the code and confirmed with a shadow fix.
- A second, independent fault-injection run (random model-based testing, a merge started from
updateDocumentswithSerialMergeScheduler) found the same root cause through a second trigger path.
No prior report: GitHub issues, PRs and commits, the JIRA archive, four mailing lists, downstream engines and the web (151 queries, 2026-10-26; refreshed 2026-11-01). The nearest items are fixed leaks of the same kind elsewhere (LUCENE-5656, LUCENE-9722). Not fixed upstream.
Searches3 searches
Verdict new. No issue, PR, list thread or commit names the reference getReaderForMerge takes and never releases when the fresh reader fails to open.
- Closest, different: #6718 / LUCENE-5656 (fixed 2014, doc-values producers leaked inside
SegmentDocValues); #10761 / LUCENE-9722 (fixed 2021, an aborted merge leaked readers already inmergeReaders); #8957 / LUCENE-7908 (a 2017 lint warning on the same lines about the new reader, closed Not A Problem). - Context: #12059 shows a real deployment hitting an
IOExceptionat the same constructor step, throughswapNewReaderWithLatestLiveDocsrather than this caller; it reports corruption, not a leak. - Symptom-level reports, not linked: LUCENE-9891, LUCENE-9070, LUCENE-7432 and #14423 (CI reports of unclosed inputs) have other diagnosed causes or no doc-values updates; #15262 (deleted
.cfsand other files held after close in an application on 9.12.2; open, no diagnosed cause) mentions neither doc-values updates nor I/O errors. - Upstream: upstream main
2f424fd428da(2026-10-30) has the same three files as645d4fe14a20; 10.6.0 has the same code and fails the test. Open PR #16571 moves one factory without changing the reference handling.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), JIRA archive | 2026-10-26; refreshed 2026-11-01 | the scripted prior-report search (two runs of 51 queries plus the finder's 49): method names (getReaderForMerge, createNewReaderWithLatestLiveDocs, initMergeReaders, preparePointInTimeMerge) with leak/decRef/exception, and symptoms ("unclosed IndexInput", "cannot close: there are still", too many open files); every GitHub hit for getReaderForMerge (18) and createNewReaderWithLatestLiveDocs (5) read; refresh: the same two names and recently updated issues matching "leak" | no report of this bug; 33 hits read, none the same bug; nothing new since 2026-10-26 |
| Open PRs by touched file | 2026-10-26 | the changed files of all 408 open PRs; 13 touch these files, 9 diffs read | none changes this reference handling; #16571 moves the mergeMiddle factory |
| Mailing lists, downstream engines and the web | 2026-10-26 | dev@, java-user@, issues@ and commits@; Elasticsearch, OpenSearch and Solr code and issues; 12 web queries | no report of this bug |
Introduced in 4.6.0 by LUCENE-5189 (doc-values updates, 2013), which made the fresh reader's constructor read files; the take-then-release order was older but harmless. 4.5.1 passes; every release run from 4.6.0 to 10.6.0 and main leaks.
Versions tested9 rows
Introduced by LUCENE-5189 "Numeric DocValues Updates" (apache/lucene#6253; SVN-era patches, no PR; issue author shaie): trunk 1120e46961b2 (2013-09-15) and the branch_4x backport 49684b82701b (2013-11-01), first released in 4.6.0. It turned the core-sharing SegmentReader constructor from one that did no I/O into one that reads the field-infos generation file and opens the doc-values update producers; the caller already took the pooled reference before it and released it only after it returned.
The code moved into ReadersAndUpdates.getReaderForMerge in 7.0.0 (LUCENE-7868, 58105a203a1) and into createNewReaderWithLatestLiveDocs in 7.4.0 (LUCENE-8246: the branch_7x commit e0dcd66ac72, a backport of the main-branch commit e99a19755c7, which first shipped in 8.0.0), keeping the order both times. In 4.6.0-4.10.4 a failed forced merge was not tragic; by 5.5.5 it is. 8.6.0 added the non-tragic merge-on-commit path and 8.7.0 merge-on-refresh through the same code (read). A tag scan finds the same order in every release tag from 4.6.0 to 10.5.2 in the history clone, which lacks the 8.8.2-8.11.4 tags (8.10.1 and 8.11.4 were run from release jars); 10.6.0 was read and run separately.
| Version or commit | Verdict | Result |
|---|---|---|
| 4.5.1 | not affected | passes: no doc-values update API; the refresh opened no file |
| 4.6.0, 4.10.4 | affected | fail: the failed forced merge is not tragic; the compound file and a doc-values file stay open after a normal close |
| 5.5.5, 6.6.6, 7.0.0, 7.7.3, 8.5.2 | affected | fail: forced merge, tragic; 2 files stay open |
| 8.6.0, 8.11.4 | affected | fail: also through merge-on-commit, not tragic |
| 8.10.1 | affected | fail (fact check): forced merge, tragic; _0.cfs and one doc-values file stay open |
| 9.12.3, 10.5.2 | affected | fail: 2 and 3 files stay open |
| 10.6.0 | affected | the minimal test fails: 4 files stay open |
main 645d4fe14a20 | affected | the minimal test fails through gradle with assertions on and off; passes with fix A or B |
upstream main 2f424fd428da | by reading | the three files are unchanged since 645d4fe14a20 |
Confirmed with high confidence: an independent reproducer, a devil's-advocate verifier and an adjudication each reproduced it with their own tests, including default-config paths where the writer stays healthy. A reviewer from another model family, unable to run code, returned a procedural "not real" while agreeing with the root cause.
Checks and verdicts353 words
Every check agrees on the root cause, and that the harm is a resource leak only.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | JUnit with a merge-on-commit (non-tragic) and a forced-merge (tragic) test: fails on main with assertions on and off (40/40 at 20 iterations), 10.5.2 and 9.12.3; passes with the fix. |
| review (OpenAI GPT-5.6, read-only) | real, high | Root cause agreed; it pointed out the second reference in the merge-reader factories, which option B releases. |
| independent reproducer | real | Own public-API tests: fail on main (assertions on and off), 10.5.2 and 9.12.3, a control passes; with a default IndexWriterConfig a plain commit fails non-tragically and 4 files stay open; fix A alone passes. |
| cross-model review, refute lens (OpenAI GPT-5.6, read-only) | not real, low (procedural) | Could not run code, so its verdict is procedural; 9 of its 10 evidence items support the bug. A separate run re-ran the repro: fails; fix A: passes. |
| devil's-advocate verifier | real | No intended, documented, misuse or unreachable argument survived. Own default-config merge-on-refresh test fails on main and 10.5.2 and passes with fix A. |
| adjudication | confirmed, high | Re-ran the reproducer's tests on a new seed: 3 of 4 fail (the control passes); fix A alone passes 4 of 4. |
| history, security screen, audit, re-checks | 4.6.0 first affected; security none; confirmed | 12 releases and main run (4.5.1 passes); the effect probe measured mapped and open files on a real file system; the minimal test fails on 645d4fe14a20 and 10.6.0, and the default-config commit test fails on 645d4fe14a20 with assertions on and off. |
| fact checks (an independent fact checker; OpenAI GPT-5.6, read-only) | real; text corrected | The fact checker re-ran the minimal test on 645d4fe14a20 (assertions on and off, 20 of 20 random seeds fail; 20 of 20 pass with fix A alone), on the 10.6.0 jars and through the documented gradle command, re-ran the default-config commit test, and found that the port also leaks on 8.10.1. The read-only reviewer checked the text against the code and the retained results. The wording errors both found are fixed in this version. |
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 453 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,717 words
- Registry title
- When a merge fails to open the refreshed reader of a segment with doc-values updates, IndexWriter never releases its reference on the pooled segment reader, so the segment's files stay open after IndexWriter.close
- Area
- index / IndexWriter merge-reader acquisition (ReadersAndUpdates.getReaderForMerge)
- Priority
- Low, rank 1087: A failed merge-reader refresh leaks one pooled segment reader and its files, a bounded leak that needs an I/O error at one exact step.
- Impact class
- minor
- Impact note
- Hits applications that update doc-values fields (updateNumericDocValue, updateBinaryDocValue) or use soft deletes, open NRT readers from the writer (which pools segment readers), and then get an I/O error (too many open files, a transient storage, permission or quota error, a read-only remount, or a throwing custom Directory) at the one moment a merge opens the refreshed reader of such a segment whose pooled reader has stale live docs; a default IndexWriterConfig reaches it through merge-on-commit and merge-on-refresh, where the failure is not tragic and the writer keeps working, and any natural or forced merge reaches it too, where the failure is tragic and the writer closes itself as designed; each failure leaves one pooled segment reader unreleased, so the segment's compound file and doc-values update files are never closed by Lucene: on MMapDirectory (the 64-bit default) they stayed mapped after IndexWriter.close, Directory.close and System.gc() and kept their disk space after a later merge deleted them, and on NIOFSDirectory they held file descriptors until garbage collection; results, deletions and later commits stay correct, no scale is needed, and every release from 4.6.0 to 10.6.0 and main is affected.
- Summary
- You update a doc-values field (or use soft deletes) and open NRT readers from your IndexWriter, then delete a document, and a merge (a merge-on-commit, a merge-on-refresh, or an ordinary or forced merge) has to open a fresh copy of that segment's pooled reader with the new deletions, and opening it fails with an IOException. You expect the merge to fail cleanly and IndexWriter to give back every reader reference it took, so that once the writer is closed no file of the index is still open. Instead ReadersAndUpdates.getReaderForMerge never releases the reference it took on the pooled reader, so that segment's compound file and doc-values update files stay open after IndexWriter.close, even when the failure was not tragic and the writer went on to commit and close normally.
- Introduced
- 1120e46961b242104410190b70d1f4e4e57d1a40 (trunk, svn r1523461) and its branch_4x backport 49684b82701b4196c9ae029b0c4d45fd6efdcb4a (svn r1537832, first released in 4.6.0), LUCENE-5189 'Numeric DocValues Updates' = apache/lucene#6253 (SVN-era patches on JIRA, no pull request; author shaie): it turned the core-sharing SegmentReader constructor used for the merge-reader refresh from a constructor that did no I/O and declared no exception into one that reads the field-infos generation file and sets up the doc-values update producers (throws IOException), while the caller kept taking the pooled reference before it and releasing it only after it returned [read: git show of 49684b82701 against its parent; the take-then-release order is already in 4.0.0-4.5.1 mergeMiddle but was harmless there; exec: 4.5.1 passes (the API did not exist and the refresh opened no file), 4.6.0 fails]. The code moved into ReadersAndUpdates.getReaderForMerge in 7.0.0 (LUCENE-7868, 58105a203a1) and into createNewReaderWithLatestLiveDocs in 7.4.0 (LUCENE-8246, e99a19755c7) and kept the same order both times [read] (2013-09-15 (trunk commit 1120e46961b2, svn r1523461); 2013-11-01 (branch_4x backport 49684b82701b, svn r1537832))
- First affected version
- 4.6.0 [exec: 4.6.0 fails, 4.5.1 passes]
- Affected versions
- every release from 4.6.0 through 10.6.0, and main ea57360912ed. [exec] 4.6.0, 4.10.4, 5.5.5, 6.6.6, 7.0.0, 7.7.3, 8.5.2, 8.6.0 (merge-on-commit), 8.11.4 (forced merge and merge-on-commit), 9.12.3, 10.5.2 and main leak (main also with -da), and the one-test minimal repro fails on the 10.6.0 release jars (SHA-1 checked against Maven Central, -ea, 2026-11-01); 4.5.1 does not (no doc-values update API, the refresh opened no file). [read] a tag scan of the release tags 4.6.0-10.5.2 in the history clone finds the same unguarded order in every one (the clone lacks the 8.8.2-8.11.x tags; 8.11.4 was executed). Trigger scope by version: 4.6.0-4.10.4 a failed forced merge is not tragic [exec]; by 5.5.5 it is tragic [exec, not bisected]; 8.6.0 adds the non-tragic merge-on-commit path [exec] and 8.7.0 merge-on-getReader through the same code [read]. Not fixed upstream: main 49483e82b357, branch_10x and branch_10_5 have the same order [read, 2026-10-26]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check; also reached by randomized/MBT, code reading
- Upstream and prior reports
- none found (2026-10-26: two scripted prior-report searches of 51 queries each (403 and 519 distinct hits) plus the finder's run of 49 queries (154 hits), over GitHub issues and PRs, commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; targeted GitHub searches with every hit for getReaderForMerge (18) and createNewReaderWithLatestLiveDocs (5) read; the changed files of all 408 open apache/lucene PRs (13 touch the files, the diffs of 9 read, none changes the reference handling); 33 hits read, none the same bug). Not fixed: upstream main 49483e82b357, branch_10x and branch_10_5 still take the reference, open the refreshed reader and release nothing on failure. Open PR #16571 ('Let a merge write more than one segment') moves the mergeMiddle merge-reader factory without changing the reference handling. Related, different: #6718 / LUCENE-5656 (fixed 2014, doc-values producers leaked inside SegmentDocValues), #10761 / LUCENE-9722 (fixed 2021, aborted merge with empty output leaked readers already in mergeReaders), #3836 / LUCENE-2762, #8957 / LUCENE-7908 (2017 lint warning on the same lines about a different object, closed Not A Problem), #9375 / LUCENE-8328, #13017 / #11751, #16625. Context, not a report: #12059 (a real deployment hit an IOException at the same constructor step through swapNewReaderWithLatestLiveDocs). Open symptom-level CI reports of unclosed inputs (LUCENE-9891, LUCENE-9070, LUCENE-7432, #14423) were read and have different diagnosed or structurally required causes.
- Lucene commit tested
- main
645d4fe14a20(2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on and off (4 open files); with fix A it passes with assertions on and off, the test plus 12 related suites pass (13 suites, 195 tests, 0 failures, 10 skipped) and the whole:lucene:core:testpasses (749 suites, 8,898 tests, 0 failures, 322 skipped), with the core module's format, ECJ and forbidden-apis checks; with fix B the test (assertions on and off) and the same 13 suites pass. The independent reproducer's default-config merge-on-commit test fails on645d4fe14a20with assertions on and off, and the minimal test fails on the 10.6.0 jars. Earlier runs: version-portable ports of the test on 4.5.1 (pass) and on 11 releases, 4.6.0, 4.10.4, 5.5.5, 6.6.6, 7.0.0, 7.7.3, 8.5.2, 8.6.0, 8.11.4, 9.12.3 and 10.5.2, plus mainea57360912edanda9779ef3c193(all fail), and the effect probe onea57360912ed. The fact check re-ran the minimal test (assertions on and off, 20 of 20 random seeds fail, 20 of 20 pass with fix A alone, 10.6.0 jars) and the default-config commit test on645d4fe14a20, and the port on 8.10.1 (fails).
- Finding record
lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:187-197 (getReader: 'Ref for caller' incRef), :1067-1085 (createNewReaderWithLatestLiveDocs: reader.decRef() only after the SegmentReader constructor and onNewReader succeed), :1093-1106 (getReaderForMerge: no handler)lucene/core/src/java/org/apache/lucene/index/SegmentReader.java:124-160 (core-sharing constructor; on failure releases only what it took) and :175-205 (initDocValuesProducer, initFieldInfos read update files only when the segment has field updates)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1342 (close: 'Closes all open resources and releases the write lock'), :4083-4088 (preparePointInTimeMerge merge-reader factory), :5484-5490 (mergeMiddle merge-reader factory); IndexReader.java:165-170 (incRef: 'Be sure to always call a corresponding decRef, in a finally clause; otherwise the reader may never be closed'); MergePolicy.java:465-481 (initMergeReaders keeps only readers the factory returned)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:187-198 (getReader, 'Ref for caller' :195-196), :213-225 (dropReaders), :1067-1086 (createNewReaderWithLatestLiveDocs; decRef :1080), :1092-1107 (getReaderForMerge)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/SegmentReader.java:124-162 (core-sharing constructor; try :155-161), :175-193 (initDocValuesProducer; :186-190), :195-205 (initFieldInfos; :197-203)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:4083-4088, :5484-5491 (merge-reader factories; getPooledInstance :4085, :5486), :5394-5425 (closeMergeReaders; rld.release(sr) :5412, release(rld) :5413)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:465-481 (initMergeReaders); ReaderPool.java:314-341 (dropAll); IndexWriterConfig.java:85-90 (DEFAULT_READER_POOLING = true)
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:85-90 (DEFAULT_READER_POOLING), :108 (DEFAULT_MAX_FULL_FLUSH_MERGE_WAIT_MILLIS = 500); IndexWriter.java:1938, :1962, :1996, :2070 (softUpdateDocument, updateNumericDocValue, updateBinaryDocValue, updateDocValues)
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1342 (close javadoc), :162-165 (closes itself after an Error or disaster)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexReader.java:164-170 (incRef javadoc; same sentence on tryIncRef :189-190)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:1078-1084; MergePolicy.java:476-479; SegmentReader.java:155-161; ReaderPool.java:327-330a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestIndexWriterOutOfFileDescriptors.java:37, :84-91, :121-131, :166; TestIndexWriterOnError.java:91, :148-150, :222, :242, :259-261a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:883 ('cannot close: there are still ... open files')a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:12377-12379 (LUCENE-5656), :13237-13238 (LUCENE-5262)https://github.com/apache/lucene/issues/12228 (read 2026-11-01: a maintainer's comment that on a tragedy it is unsafe to delete files)a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/codecs/cranky/CrankyFieldInfosFormat.java:37-40, CrankyDocValuesFormat.java:49-50, CrankyCompoundFormat.java:37-38 (delegate without injecting); CrankyPointsFormat.java:98-233 (CrankyPointsReader, the read-side injections); TestIndexWriterOnError.java:320-321 (nightly testCheckpoint: only under IndexFileDeleter.checkpoint)
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:1088-1090 (swapNewReaderWithLatestLiveDocs); IndexWriter.java:5413 (release(rld) in closeMergeReaders)a Lucene clone release tags 10.6.0 and 9.12.3: git apply --check of fix A on ReadersAndUpdates.java (offsets +62 and -309 lines)https://github.com/apache/lucene/pull/16571 (open, updated 2026-10-30)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:1105 (readerConsumer.accept); IndexFileDeleter.java:612 (incRef(Collection), no IOException)
- Prior reports
gh search issues/prs --repo apache/lucene getReaderForMerge (16 issues, 2 PRs) and createNewReaderWithLatestLiveDocs (3 issues), 2026-11-01; gh search issues leak --sort updated (20 read by title); gh issue view 15262 (read)gh api repos/apache/lucene/commits (main 2f424fd428da, 2026-10-30; last commits to ReadersAndUpdates.java 2026-09-21, IndexWriter.java 2026-09-21, SegmentReader.java 2026-04-01)
- History
a Lucene clone releases/lucene/10.6.0 (4ae946e5a9b): ReadersAndUpdates.java:1157-1163 (same unguarded order)
- TestMergeReaderRefreshFailureLeak.java:
repro/LFV-446/src/org/apache/lucene/index/TestMergeReaderRefreshFailureLeak.java(65 lines)
Made in parallel with this doc from the code at 645d4fe14a20, the minimal test and a probe of the same test that prints every value drawn (the pooled reader's reference counts, the open files and the failing stack), run with assertions on and off and with the fix.
assets/LFV-446/bug.png(453 KB)
