All findings · Lucene finding · LFV-446 · getreaderformerge-leaks-pooled-reader-ref-when-refresh-fails

A failed merge-reader refresh leaks the pooled segment reader, so the segment's files stay open after IndexWriter.close

If a merge hits an I/O error while opening a fresh reader for a segment with doc-values updates (or soft deletes), IndexWriter never releases its reference on that segment's pooled reader. In a 2-document repro, 4 files stay open after IndexWriter.close(); every release since 4.6.0 has it.

Summary

Priority
Low, rank 1087A 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
minor
Since
4.6.0 (LUCENE-5189, doc-values updates, 2013); 4.5.1 is not affectedintroduced 2013-09-15 · over 13 years
Affected
4.6.0 to 10.6.0, and main
Status
documented
Found by
fault injection at every I/O point of IndexWriter's index-level operations, then a JUnit repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background: pooled readers and the merge refresh

  1. IndexWriter keeps at most one SegmentReader per 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.
  2. 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.
  3. 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.
Summary card of the bug. Panel 1, the user's calls: two documents with a numeric doc-values field, updateNumericDocValue on a, an NRT reader opened and closed, delete b, forceMerge(1) while one file open throws an injected IOException, then close. Panel 2, the result: expected no open file; main leaves 4 open files, _0.cfs and three _0_1_Lucene90_0 doc-values files; with the fix none. Panel 3, the pooled reader's reference count across five steps, each labelled with its code line: main 1, 2, 2, 1, 1 versus expected 1, 2, 1, 0, 0.
The pooled reader's reference count across the failed merge: on main 1, 2, 2, 1, 1 where 1, 2, 1, 0, 0 is expected, because 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.

The finding, step by step

  1. 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.
  2. Delete "b". The pooled reader does not have that delete yet, so a merge of _0 must first open a fresh copy of it; because _0 has a doc-values update, that copy re-reads the update's field-infos file from disk.
  3. Run forceMerge(1) while that file open fails with an IOException. You expect the merge to fail and, once IndexWriter.close() has returned, no index file to be open.
  4. Instead 4 files stay open: the pooled reader's _0.cfs and its three _0_1_Lucene90_0 doc-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.
  5. 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.

Who is affected

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.

Mechanism (Lucene main 645d4fe14a20)

// 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;
}
  1. Every merge of the writer's own segments gets its readers through getReaderForMerge, from the merge-reader factories in IndexWriter.java:4083-4088 (merge-on-commit and merge-on-refresh) and :5484-5491 (all other merges), via OneMerge.initMergeReaders (MergePolicy.java:465-481); addIndexes(CodecReader...) merges use the caller's readers instead (MergePolicy.java:256).
  2. ReadersAndUpdates.java:1095 takes a reference on the pooled reader. When its live docs are stale (:1096-1097), :1100 calls createNewReaderWithLatestLiveDocs.
  3. The core-sharing SegmentReader constructor (SegmentReader.java:128-162) calls initFieldInfos() and initDocValuesProducer() (: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.
  4. So :1080 never runs and neither method releases the reference from :1095. initMergeReaders keeps only the readers the factory returned (MergePolicy.java:474-479), so the merge's cleanup (IndexWriter.closeMergeReaders, :5394-5425) never sees this one.
  5. When the writer closes (IndexWriter.close(); in the minimal test already the rollback that the tragic failure runs inside forceMerge, IndexWriter.java:2677), ReaderPool.dropAll (ReaderPool.java:314-341) calls ReadersAndUpdates.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.
  6. 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 a ReadersAndUpdates reference with getPooledInstance(sci, true) (IndexWriter.java:4085, :5486) and do not release it when getReaderForMerge throws. With fix A alone no file stays open; the only observed effect of this second leak is that the segment's ReadersAndUpdates reference 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 custom Directory that 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 main 645d4fe14a20 with assertions on and off; merge-on-refresh executed on ea57360912ed and 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): on MMapDirectory, the 64-bit default, no descriptor is held, but the 4 leaked files, deleted by a later merge, were still mapped after IndexWriter.close(), Directory.close() and five System.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). On NIOFSDirectory, 4 descriptors on the deleted files stayed open after IndexWriter.close() and Directory.close() (1,056,459 bytes in the refresh arm, 1,056,460 in the force-merge arm) and were released only after the five System.gc() calls.
  • Not affected. Search results, deletions and later commits: no deleted document became visible in any run.

Minimal repro

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().

TestMergeReaderRefreshFailureLeak.java65 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.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());
    }
  }
}
Command
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 injected IOException, and after IndexWriter.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).

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails with assertions on and off; 10.6.0: fails; with fix A or B: passes
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# 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 failed

Why it looks unintended

  • IndexWriter.close() "Closes all open resources"; IndexReader.incRef: always pair it with decRef, "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 corresponding decRef, in a finally clause; otherwise the reader may never be closed." (IndexReader.java:166-168).
  • The surrounding code is exception-safe. createNewReaderWithLatestLiveDocs releases the new reader when onNewReader fails (ReadersAndUpdates.java:1081-1083); initMergeReaders keeps 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 MockDirectoryWrapper after injected failures. TestIndexWriterOutOfFileDescriptors injects IOExceptions on file opens, lets merges fail (tragically, when they hit the merge scheduler), rolls the writer back and closes a MockDirectoryWrapper, 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. TestIndexWriterOnError uses doc-values updates, but its failures throw an Error with a 1-in-3000 draw at each failure check while IndexWriter is on the stack. CrankyCodec never 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.

Suggested fix

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 decRef cannot close the reader: the pool still holds its own reference, and the method holds the ReadersAndUpdates lock.
  • 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 with patch --dry-run: offsets +62 and -309; not built there).
  • Scope: A covers a failure anywhere in createNewReaderWithLatestLiveDocs. A failure after the refresh, in readerConsumer.accept (ReadersAndUpdates.java:1105), is not covered; the two current consumers call IndexFileDeleter.incRef(Collection), which declares no IOException, 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 plus TestIndexWriterOutOfFileDescriptors, TestIndexWriterOnError, TestIndexWriterExceptions, TestIndexWriterExceptions2, TestIndexWriterReader, TestIndexWriterMergePolicy, TestNumericDocValuesUpdates, TestBinaryDocValuesUpdates, TestMixedDocValuesUpdates, TestSoftDeletesRetentionMergePolicy, TestPendingSoftDeletes and TestReaderPool pass (13 suites, 195 tests, 0 failures, 10 skipped); the whole :lucene:core:test passes (749 suites, 8,898 tests, 0 failures, 322 skipped).
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest of :lucene:core pass 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 ReadersAndUpdates count staying at 1 after close).
  • Open PR #16571 ("Let a merge write more than one segment") moves the mergeMiddle factory 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, seeds DEADBEEF and 1234ABCD, 0 failures).

Downstream note

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).

How it was found

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.initFieldInfos under createNewReaderWithLatestLiveDocs and getReaderForMerge.
  • 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 updateDocuments with SerialMergeScheduler) found the same root cause through a second trigger path.

Prior reports, history and checks

Prior reports

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 in mergeReaders); #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 IOException at the same constructor step, through swapNewReaderWithLatestLiveDocs rather 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 .cfs and 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 as 645d4fe14a20; 10.6.0 has the same code and fails the test. Open PR #16571 moves one factory without changing the reference handling.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), JIRA archive2026-10-26; refreshed 2026-11-01the 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 file2026-10-26the changed files of all 408 open PRs; 13 touch these files, 9 diffs readnone changes this reference handling; #16571 moves the mergeMiddle factory
Mailing lists, downstream engines and the web2026-10-26dev@, java-user@, issues@ and commits@; Elasticsearch, OpenSearch and Solr code and issues; 12 web queriesno report of this bug

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
4.5.1 not affectedpasses: no doc-values update API; the refresh opened no file
4.6.0, 4.10.4 affectedfail: 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 affectedfail: forced merge, tragic; 2 files stay open
8.6.0, 8.11.4 affectedfail: also through merge-on-commit, not tragic
8.10.1 affectedfail (fact check): forced merge, tragic; _0.cfs and one doc-values file stay open
9.12.3, 10.5.2 affectedfail: 2 and 3 files stay open
10.6.0 affectedthe minimal test fails: 4 files stay open
main 645d4fe14a20 affectedthe minimal test fails through gradle with assertions on and off; passes with fix A or B
upstream main 2f424fd428da by readingthe three files are unchanged since 645d4fe14a20

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateJUnit 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, highRoot cause agreed; it pointed out the second reference in the merge-reader factories, which option B releases.
independent reproducer realOwn 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 realNo 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, highRe-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; confirmed12 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 correctedThe 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.

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

Finding record

LFV-446, snapshot of 2026-11-06

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:test passes (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 on 645d4fe14a20 with 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 main ea57360912ed and a9779ef3c193 (all fail), and the effect probe on ea57360912ed. 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 on 645d4fe14a20, and the port on 8.10.1 (fails).

Lucene and upstream sources

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-330
  • a 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-261
  • a 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)

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, 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.

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.