All findings · Lucene finding · LFV-1065 · iw-ctor-failure-leaks-reader-pool-segment-readers

IndexWriter opened from a reader's IndexCommit keeps the reader's segment files open when its constructor fails

If you open an IndexWriter with setIndexCommit(reader.getIndexCommit()) and the constructor fails, for example because your deletion policy's onInit throws, the writer has already taken a reference on each of the reader's segments and never releases it. After you close the reader its segment files stay open (_0.cfs for one segment).

Summary

Priority
Low, rank 1090When an IndexWriter opened from a reader's commit fails in its constructor, one reference per segment of that reader is never released, so the reader's files stay open, in every release since 7.4.0. It is a bounded leak after a loud failure on an opt-in Expert path, so it is Low.
Impact
minor
Since
7.4.0 (LUCENE-8260, 8975692953, 2018-04-23)introduced 2018-04-23 · about 8 years
Affected
7.4.0-10.6.0 and main; only writers opened from a reader's commit whose constructor fails
Status
documented
Found by
non-formal: code reading of the constructor's failure handler, then one public-API JUnit test

AI-assisted page: this finding was first signalled by code reading or testing 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

Background: segment cores and opening a writer from a reader

  1. A DirectoryReader has one segment reader per segment. The segment's open files (for a small segment one compound file, _0.cfs) live in a shared core with a reference count; the files close when the count reaches 0.
  2. IndexWriterConfig#setIndexCommit(reader.getIndexCommit()) (Expert) opens a writer at exactly the point an open reader sees, NRT or not.
  3. On that path the writer enrolls its own segment reader for each of the reader's segments in its ReaderPool, sharing the reader's cores: one more reference per core, normally released when the writer closes.
Diagram of the bug: a DirectoryReader over one segment holds a reference on segment _0's core (file _0.cfs). A new IndexWriter opened from the reader's IndexCommit first builds its ReaderPool, which adds a second reference on that core, then calls the deletion policy's onInit, which throws. The constructor's catch closes the merge scheduler and the write lock but not the pool. After reader.close() the core still has one reference, so _0.cfs stays open. The fix adds the pool to the catch.
One failed writer open on a one-segment index: the reader holds _0's core, the writer's pool adds a second reference, onInit throws, the catch releases only the write lock and the merge scheduler, and closing the reader leaves _0.cfs open. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 1 document and close the writer: one segment, stored as the compound file _0.cfs. Open DirectoryReader r on it.
  2. Open a writer from the reader's commit, new IndexWriterConfig().setIndexCommit(r.getIndexCommit()), with a deletion policy whose onInit throws IOException. The constructor throws, as it should.
  3. You expect nothing left behind: you hold only r, so after r.close() the directory should close cleanly, as it does when the commit comes from DirectoryReader.listCommits.
  4. Instead _0.cfs stays open: cannot close: there are still 1 open files: {_0.cfs=1}. Before the policy ran, the constructor put one segment reader per segment into its ReaderPool, each holding a reference on the segment's core; its catch releases the write lock and the merge scheduler, not the pool.
  5. Every failed attempt leaks one more reference per segment (2 files for 2 segments). No public API can release them, and a later successful writer does not either.

Who is affected

Applications that open a writer from an open reader's commit (setIndexCommit(reader.getIndexCommit()), Expert, opt-in; NRT and non-NRT readers) whose constructor then fails: a deletion policy whose onInit throws, or an I/O error while the writer reads a commit. One leaked core reference per segment per failed attempt; results stay correct.

Mechanism, terms and numbers669 words

IndexWriterConfig#setIndexCommit documents that a writer can be opened from an open reader by passing reader.getIndexCommit(). On that path the IndexWriter constructor first builds its ReaderPool, which pre-enrolls one SegmentReader per segment of the reader, each sharing the reader's segment core and taking a reference on it. Only then does it build the IndexFileDeleter, which reads every segments_N and calls IndexDeletionPolicy.onInit. If anything throws from then on, the constructor's catch (Throwable) closes the merge scheduler and the write lock but not the pool, and the caller never gets the writer. So after the caller closes the reader, each segment core still holds the pool's reference and its files stay open: with one one-document commit and a policy whose onInit throws, MockDirectoryWrapper reports {_0.cfs=1} still open, with assertions on and off, on main and on every release from 7.4.0 to 10.6.0. The same failing policy with a commit from listCommits, or with no commit, leaves nothing open. An I/O error while the deleter reads a commit leaks the same way with the default policy. The leak arrived as a side effect of LUCENE-8260, which moved the pre-enroll step ahead of the deleter. Results and index files are unaffected. Closing the pool in the catch, as #15675 did for the merge scheduler, fixes it.

Terms

  • Segment core (SegmentCoreReaders): the per-segment open files and readers shared by every SegmentReader of that segment. Each sharing reader takes a reference (SegmentReader.java:151-152, core.incRef()); when the count reaches 0 the core closes its files, including the compound-file input (SegmentCoreReaders.java:167-179, opened at :83).
  • Reader commit: DirectoryReader.getIndexCommit() on a standard reader returns a ReaderCommit that carries the reader (StandardDirectoryReader.java:571-574, :644-646); a commit from listCommits is a ReaderCommit built with a null reader (DirectoryReader.java:456, :481; field set at StandardDirectoryReader.java:595), so its getReader() returns null.
  • Pre-enrollment: the ReaderPool constructor's loop that pools one SegmentReader per leaf of that reader, "so any in-memory NRT live docs are correctly carried over, and so NRT readers pulled from this IW share the same segment reader" (ReaderPool.java:90-115).

The bug (main 645d4fe14a20)

  1. The constructor takes the write lock (IndexWriter.java:954), enters try (:956) and gets the commit's reader, reader = commit.getReader() (:982-990).
  2. readerPool = new ReaderPool(..., reader, config) (:1148-1158) pre-enrolls one SegmentReader per segment, each adding a reference to the reader's core (ReaderPool.java:96-113).
  3. Only then deleter = new IndexFileDeleter(...) (IndexWriter.java:1166-1182) reads every commit (IndexFileDeleter.java:161) and calls policy.onInit(commits) (:237).
  4. If that throws, catch (Throwable t) (IndexWriter.java:1202-1209), "init: hit exception on init; releasing write lock", runs IOUtils.closeWhileSuppressingExceptions(t, config.getMergeScheduler(), writeLock) (:1206). The pool is not in that list, and it lives in a private final field (:403) of a writer the caller never receives.
  5. r.close() removes the reader's own reference, but the pooled reader's reference stays: the core never reaches 0 and _0.cfs stays open.

The failed writer stays reachable from the config, which stores it through the package-private setIndexWriter in a private SetOnce field (IndexWriter.java:947, IndexWriterConfig.java:119, :126-132), so the exact wording is "cannot be released through any public API".

The numbers (executed, MockDirectoryWrapper)

casefiles open after the reader is closed, expectedactual
1 segment, reader commit, onInit throws01 {_0.cfs=1}
2 segments, reader commit, onInit throws02 {_1.cfs=1, _0.cfs=1}
1 segment, then a successful retry writer, closed01 {_0.cfs=1}
same policy, commit from listCommits00
same policy, no commit set00

Other triggers of the same missing release (fault-injection matrix, assertions on and off): an NRT reader's commit with the throwing onInit; an I/O error while the deleter reads a commit, with the default policy; and two failures inside the pre-enroll loop, an I/O error while a later segment's pooled reader reads its field infos and LFV-1062's leaf-sorted unequal-size IllegalArgumentException, which leak the readers pooled so far.

Exposure and likelihood351 words
  • Who is hit. Writers opened with IndexWriterConfig#setIndexCommit(reader.getIndexCommit()), the Expert path LUCENE-6524 added in 5.3.0 ("IndexWriter can now be initialized from an already open near-real-time or non-NRT reader"), whose constructor fails after the pool is built. Lucene's own RandomIndexWriter takes this path at random in tests (RandomIndexWriter.java:96-104).
  • Default vs opt-in. Opt-in: by default no commit is set ("The default is null which opens the latest commit point", IndexWriterConfig.java:234-241). A commit from listCommits, or none, is not affected.
  • What triggers it. An application IndexDeletionPolicy whose onInit throws the IOException it declares (none of the stock policies throws there); an I/O error while IndexFileDeleter reads a segments_N, with the default policy; or a failure inside the pre-enroll loop (an I/O error on a later segment, or LFV-1062's leaf-sorted reader with segments of unequal size).
  • How much. One segment-core reference per segment per failed attempt, growing with each attempt; a successful retry does not release the earlier one. Open file handles grow per attempt only when each attempt uses a fresh reader: three failed attempts with the same reader leave {_0.cfs=1}, three with a fresh reader each leave {_0.cfs=3}. The write lock and the merge scheduler are released, so a retry can open.
  • At the OS level (one Linux probe, not part of the claim): after the failed open the caller dropped the reader, a normal writer deleted _0.cfs, and 5 System.gc() calls ran. With MMapDirectory, which FSDirectory.open returns on 64-bit JVMs (FSDirectory.java:155-161), the process still had 3 mappings of the deleted _0.cfs (control: 0); they belong to a shared arena that only an explicit close releases, so the file's disk space stays allocated as long as they exist (not measured beyond the probe). With NIOFSDirectory the one leaked file descriptor was reclaimed by garbage collection once the reader and the config were dropped.
  • What does not happen. No wrong results, no index damage, no hang; with an injected I/O error while the deleter reads a commit, the committed file set is unchanged, with no unreferenced or missing files. The constructor fails loudly.

Minimal repro

One JUnit test on the public API: newMockDirectory(), one one-document commit, setIndexCommit(r.getIndexCommit()) and a deletion policy whose onInit throws. After the expected IOException it closes the reader, then the directory, which fails if a file is still open. Deterministic, single thread, about a second.

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

public class TestIndexWriterFromReaderFailedOpen extends LuceneTestCase {

  public void testFailedOpenFromReaderCommitReleasesSegmentReaders() throws Exception {
    Directory dir = newMockDirectory();
    IndexWriter w = new IndexWriter(dir, new IndexWriterConfig());
    w.addDocument(new Document());
    w.close();

    DirectoryReader r = DirectoryReader.open(dir);
    IndexWriterConfig iwc =
        new IndexWriterConfig()
            .setIndexCommit(r.getIndexCommit())
            .setIndexDeletionPolicy(
                new IndexDeletionPolicy() {
                  @Override
                  public void onInit(List<? extends IndexCommit> commits) throws IOException {
                    throw new IOException("onInit failed");
                  }

                  @Override
                  public void onCommit(List<? extends IndexCommit> commits) {}
                });
    expectThrows(IOException.class, () -> new IndexWriter(dir, iwc));

    r.close();
    // expected: dir.close() succeeds; actual: "there are still 1 open files: {_0.cfs=1}"
    dir.close();
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestIndexWriterFromReaderFailedOpen.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestIndexWriterFromReaderFailedOpen -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure); the outcome does not depend on the seed
Expected
After r.close(), dir.close() succeeds: the failed constructor left no segment file open.
Observed
MockDirectoryWrapper: cannot close: there are still 1 open files: {_0.cfs=1} (an unclosed _0.cfs opened by the segment core), assertions on and off, on main and 10.6.0.

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

More files, environment, full output255 words

The smallest input: one segment, and a policy that always throws, so the failure comes right after the pool is built. The oracle is MockDirectoryWrapper's own open-file check on close(). The test sits in org.apache.lucene.index next to TestIndexWriterFromReader but uses no package-private API. The same test is the regression test. Assertions on and off, and other seeds (1065C0DE, C0FFEE12), give the same failure. All runs were on Linux aarch64 with OpenJDK 25.0.3. The missing release is plain Java control flow, so it does not depend on the platform; the OS-level effects above are Linux observations.

Environment

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

Observed output

# gradle, stock 645d4fe14a20, seed DEADBEEF, assertions on
TestIndexWriterFromReaderFailedOpen > testFailedOpenFromReaderCommitReleasesSegmentReaders FAILED
    java.lang.RuntimeException: MockDirectoryWrapper: cannot close: there are still 1 open files: {_0.cfs=1}
        at org.apache.lucene.tests.store.MockDirectoryWrapper.close(MockDirectoryWrapper.java:884)
        at org.apache.lucene.index.TestIndexWriterFromReaderFailedOpen.testFailedOpenFromReaderCommitReleasesSegmentReaders(TestIndexWriterFromReaderFailedOpen.java:51)
        Caused by:
        java.lang.RuntimeException: unclosed IndexInput: _0.cfs
            at org.apache.lucene.tests.store.MockDirectoryWrapper.addFileHandle(MockDirectoryWrapper.java:782)
            at org.apache.lucene.tests.store.MockDirectoryWrapper.openInput(MockDirectoryWrapper.java:843)
            at org.apache.lucene.codecs.lucene90.Lucene90CompoundReader.<init>(Lucene90CompoundReader.java:87)
            at org.apache.lucene.codecs.lucene90.Lucene90CompoundFormat.getCompoundReader(Lucene90CompoundFormat.java:91)
            at org.apache.lucene.index.SegmentCoreReaders.<init>(SegmentCoreReaders.java:83)

# assertions off (-Ptests.asserts=false), seeds DEADBEEF and 1065C0DE: the same failure
# 10.6.0, 10.5.2 and 9.12.3 release jars: the same failure

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

# closest suites + the repro, seed DEADBEEF: TestIndexWriter (119), TestIndexWriterExceptions (40),
# TestConcurrentMergeScheduler (21), TestIndexWriterFromReader (12), TestIndexFileDeleter (11), TestDeletionPolicy (8),
# TestReaderPool (6), TestIndexWriterOnDiskFull (4), TestIndexWriterExceptions2 (1)
stock:          classes=10 tests=223 failures=1 errors=0 skipped=5   (the one failure is the repro)
with fix A:     classes=10 tests=223 failures=0 errors=0 skipped=5
with fix A + B: classes=10 tests=223 failures=0 errors=0 skipped=5

Why it looks unintended

  • The constructor's catch exists to release what it acquired: the write lock and, since #15675, the merge scheduler ("MergeScheduler should be closed on IndexWriter init failure").
  • onInit declares throws IOException; an I/O error while reading a commit needs no user code.
  • Lucene's own RandomIndexWriter opens writers from reader commits and, if the constructor fails, closes only the reader.
The full argument254 words
  • The catch is cleanup. Its log line reads "init: hit exception on init; releasing write lock" (IndexWriter.java:1204). LUCENE-2476 (#3550, 2010) added the write-lock release on any constructor exception; #15675 (GITHUB#14971, 2026) added the merge scheduler plus TestConcurrentMergeScheduler#testMergeSchedulerClosedOnIndexWriterInitFailure (:965-987), whose trigger is itself a user error (OpenMode.APPEND on an empty directory). The pool is the one resource holding open files that the catch still misses (the DocumentsWriter and a half-built IndexFileDeleter are not closed either, but hold no open files).
  • The trigger is in contract. public abstract void onInit(List<? extends IndexCommit> commits) throws IOException (IndexDeletionPolicy.java:62), with no "must not throw" note (:46-61). The I/O-error trigger uses the default policy.
  • Lucene's tests assume closing the reader is enough. RandomIndexWriter opens a writer from reader.getIndexCommit() at random and, if the constructor fails, closes only the reader (RandomIndexWriter.java:96-125). TestIndexWriterFromReader#testWithNoFirstCommit (:87) and #testOnClosedReader (:380) close their directory after a failed constructor; their failures come before the pool has enrolled any reader (testOnClosedReader fails at reader.leaves() inside the ReaderPool constructor, ReaderPool.java:94). No test asserts or tolerates the leak.
  • History: a side effect of a refactor. Through 7.3.1 the constructor built the IndexFileDeleter first and pre-enrolled after it, so an onInit or deleter failure found an empty pool (7.3.1 passes the test). LUCENE-8260 (#9307, "Extract ReaderPool from IndexWriter", 2018) moved the loop into the ReaderPool constructor, ahead of the deleter, and kept the handler as it was; the commit message describes only the extraction.
The devil's-advocate check132 words

The strongest case for "intended" (the devil's-advocate verifier and the intended-lens reviewer): setIndexCommit is "Expert" and getIndexCommit is "Expert" and @lucene.experimental (DirectoryReader.java:644-649); no javadoc promises that a failed constructor releases everything (its @throws covers I/O errors only, IndexWriter.java:941-943); and the main trigger is a user policy that throws, which could be called user error.

Why it fails: the catch exists for cleanup, and maintainers already fixed a leak in this exact catch whose trigger was a user error (#15675); onInit declares IOException; the I/O-error trigger needs no user code; Lucene's own test framework closes only the reader after a failed constructor on this path; and the leak came in by accident with a refactor. The argument explains the Low priority, not the leak. All reviewers rated it weak.

Suggested fix

Recommended Release the pool when the constructor fails after or while building it (two hunks)

Keep the ReaderPool in a local and add it to the constructor's catch: IOUtils.closeWhileSuppressingExceptions(t, pool, config.getMergeScheduler(), writeLock); also let the ReaderPool constructor drop what it pooled when its pre-enroll loop fails. Validated through gradle: the repro passes with assertions on and off, and 222 tests of the closest suites pass.

// IndexWriter(Directory, IndexWriterConfig)
+    ReaderPool pool = null; // closed if this constructor fails after creating it
     try {
-      readerPool = new ReaderPool(/* ... */, reader, config);
+      pool = new ReaderPool(/* ... */, reader, config);
+      readerPool = pool;
       // ... IndexFileDeleter, onInit ...
     } catch (Throwable t) {
-      IOUtils.closeWhileSuppressingExceptions(t, config.getMergeScheduler(), writeLock);
+      IOUtils.closeWhileSuppressingExceptions(t, pool, config.getMergeScheduler(), writeLock);
All options and trade-offs3 options ยท 782 words

The hunt's two-hunk diff, unchanged, and its first hunk alone, each run through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF) with the minimal repro and the closest suites; the fault-injection matrix shows which hunk fixes which trigger.

Release the pool when the constructor fails after or while building it (two hunks) (validated by the checks) (recommended)

Hunk A, IndexWriter: keep the pool in a local, assign the field from it, and add it to the catch's close list (+4/-2). ReaderPool is Closeable and close() drops every pooled reader (ReaderPool.java:45, :233-237, :314-341); the helper ignores null (IOUtils.java:97-106). The snippet above is condensed.

Hunk B, ReaderPool constructor (+30/-18, mostly re-indentation): wrap the pre-enroll loop so a failure inside it releases what was pooled so far, and decRef the reader whose ReadersAndUpdates could not be built. Without it, a failure inside the loop leaves pool null in the writer's catch.

      try {
        for (int i = 0; i < leaves.size(); i++) {
          // ... SegmentReader newReader = new SegmentReader(...) ...
          ReadersAndUpdates rld;
          try {
            rld = new ReadersAndUpdates(/* ... */, newReader, /* ... */, config);
          } catch (Throwable t) {
            IOUtils.closeWhileSuppressingExceptions(t, newReader::decRef);
            throw t;
          }
          readerMap.put(newReader.getOriginalSegmentInfo(), rld);
        }
      } catch (Throwable t) {
        // release the readers pre-enrolled so far: the caller never gets this pool
        IOUtils.closeWhileSuppressingExceptions(t, this::dropAll);
        throw t;
      }
// IndexWriter(Directory, IndexWriterConfig)
+    ReaderPool pool = null; // closed if this constructor fails after creating it
     try {
-      readerPool = new ReaderPool(/* ... */, reader, config);
+      pool = new ReaderPool(/* ... */, reader, config);
+      readerPool = pool;
       // ... IndexFileDeleter, onInit ...
     } catch (Throwable t) {
-      IOUtils.closeWhileSuppressingExceptions(t, config.getMergeScheduler(), writeLock);
+      IOUtils.closeWhileSuppressingExceptions(t, pool, config.getMergeScheduler(), writeLock);

Trade-offs

  • No change on success: one local variable and one try.
  • Coverage. Hunk A alone fixes the minimal repro, the onInit trigger (NRT and non-NRT) and the deleter-read trigger; hunk B alone fixes only the two in-loop triggers; both together pass all 8 tests of the fault-injection matrix.
  • Test. The regression test (the minimal test) exercises hunk A only; hunk B is backed by the fault-injection matrix, which is evidence, not a proposed test.
  • Overlap. LFV-1062's name-pairing fix edits the same loop: the diffs overlap in text but are logically independent (a combined patch was not executed).
  • One edge left in hunk B (the cross-model fact check): if readerMap.put itself throws, in practice only a VM error such as OutOfMemoryError, after the current reader's ReadersAndUpdates was built, that reader is not yet in the map and dropAll() misses it; an "enrolled" flag around the put would close it. Not material in practice.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, 2026-11-02: the minimal test fails on stock with -Ptests.asserts=true and =false (and a second seed) and passes with the fix both ways; the closest suites (9 classes, 222 tests, 5 skipped) pass on stock and with the fix.
  • google-java-format 1.37.0, run standalone: no line of the fix changes. spotlessCheck was not run, and the gradle runs had no eclint configured.
  • Earlier: the hunt's shadow check (assertions on and off), the matrix 8 of 8 with both hunks, 78 upstream tests on stock and with the fix, and the refute reviewer's run (19 upstream tests including the #15675 test).

Hunk A only: close the pool in the constructor's catch (validated by the checks)

The IndexWriter hunk alone (+4/-2), the snippet of the recommended option.

// IndexWriter(Directory, IndexWriterConfig)
+    ReaderPool pool = null; // closed if this constructor fails after creating it
     try {
-      readerPool = new ReaderPool(/* ... */, reader, config);
+      pool = new ReaderPool(/* ... */, reader, config);
+      readerPool = pool;
       // ... IndexFileDeleter, onInit ...
     } catch (Throwable t) {
-      IOUtils.closeWhileSuppressingExceptions(t, config.getMergeScheduler(), writeLock);
+      IOUtils.closeWhileSuppressingExceptions(t, pool, config.getMergeScheduler(), writeLock);

Trade-offs

The smallest change, and fully covered by the regression test. Failures inside the pre-enroll loop (an I/O error while a later segment is pre-enrolled, LFV-1062's IllegalArgumentException) still leak the readers pooled so far.

What the checks validated

Gradle, same worktree and seed: the minimal test passes with assertions on and off; the closest suites (222 tests) pass. In the matrix it fixes testA, testA2 and testB and leaves testC and testD failing.

Build the IndexFileDeleter before the ReaderPool again (not run)

Restore the pre-7.4.0 order, so an onInit or deleter failure finds an empty pool.

Trade-offs

Changes initialization order in a constructor that LUCENE-8260 deliberately restructured; whether the deleter or later steps rely on the pool existing was not checked; failures after the pool is built and inside the loop would still leak. Hunk A is smaller and covers strictly more.

Downstream note

Services that retry a failing writer open from a reader's commit leak one segment-core reference per segment per attempt, and one more open handle per segment file per attempt when each attempt opens a fresh reader; with MMapDirectory one probe still saw a deleted segment file mapped after 5 garbage collections.

How it was found

Non-formal: reading the IndexWriter constructor's failure handler showed it never closes the ReaderPool; a small program with a throwing onInit and an injected I/O error confirmed the leak. The hunt reduced it to one public-API JUnit test and checked more triggers with a fault-injection matrix on the stock jar.

The method in full144 words

Method: non-formal. A code-reading probe read the constructor's catch and saw that the pool built before the deleter is never closed (2026-10-30). Its executed main() probe leaked with a throwing onInit and in an I/O-error mode, and was clean in a no-failure control; a separate re-run confirmed it.

  • A hunt unit (2026-11-01) wrote an exception-edge map of the reader-commit branch (file:line mapped) and ran it as a fault-injection matrix on the stock jar: a throwing onInit (non-NRT and NRT reader), MockDirectoryWrapper I/O failures scoped to IndexFileDeleter.<init> and ReaderPool.<init>, LFV-1062's leaf-sorted unequal-size reader, two controls and a deleter file-set check. 5 of 8 fail on stock, 8 of 8 pass with the fix, and each hunk alone fixes only its own triggers.
  • It reduced the lead to the one-test repro, traced the history to LUCENE-8260 and ran the test on releases back to 7.3.1.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits, the open PRs' diffs, JIRA, the mailing lists, downstream projects and the web (2026-11-01). #14971 / #15675 fixed a sibling leak, the merge scheduler, in the same catch: the precedent.

Searches3 searches

Context only: #14971 / #15675 (merged 2026-02-22; added config.getMergeScheduler() to the same catch and an upstream test, never mentions the reader pool), #9307 (LUCENE-8260) and #7582 (LUCENE-6524) (the origins; no discussion of exceptions after the pool is built), #3550 (LUCENE-2476), #4734 (LUCENE-3660), #1169 (LUCENE-91), #1790 (LUCENE-715) (constructor failures that kept the write lock), #6718 (LUCENE-5656, compound-file handles leaking on another path), #16625 / open PR #16643 (rollback deletes files of pooled readers: another site, the opposite symptom), open PRs #16948, #16847, #16888, #16923 (same files, not the catch).

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA; the web2026-11-01three scripted prior-report searches (52, 51 and 24 queries; 580, 1359 and 2119 hits) and 70 manual GitHub searches: readerPool + constructor/init/exception, setIndexCommit + getIndexCommit / NRT / leak, onInit throws, "unclosed IndexInput", "releasing write lock", LUCENE-8260, LUCENE-6524, "pre-enroll"no report; context #14971, #15675, #9307, #7582, #3550, #4734, #1169, #1790, #6718, #16625, #16643
Open PRs (apache/lucene)2026-11-01changed files of all 409 open PRs; the diffs of the 18 that touch IndexWriter.java or ReaderPool.java; every item updated in the last 48 hoursnone changes the catch or the loop's exception handling (#16948, #16847, #16888, #16923 touch the same files)
apache/lucene main, branch_11x, branch_10_62026-11-01refresh: last commits on the fix-site files by author datenothing new; last changes #16581 (2026-09-21), #16570 (2026-09-01), #15105 (2025-08-21)

History

Introduced by LUCENE-8260 (8975692953, branch_7x 1bf56037d17, 2018-04-23), which moved the pre-enroll step ahead of the deleter; first released in 7.4.0 (7.3.1 and the commit's parent pass). Every release through 10.6.0 and main is affected; failures inside the loop leak since 5.3.0. Not fixed upstream.

Versions tested7 rows

Introduced by LUCENE-8260 "Extract ReaderPool from IndexWriter" (#9307, s1monw): 8975692953 on master and the branch_7x backport 1bf56037d17, both author date 2018-04-23 (JIRA era, no apache/lucene PR). It moved the pre-enroll loop into the new ReaderPool constructor, which runs before the IndexFileDeleter; the failure handler kept releasing only the write lock. Built from source, the backport fails the test and its parent 4cd0a0aae32 passes (assertions on and off).

Every local release tag from 7.4.0 to 8.8.1 and 9.0.0 to 10.6.0 builds the pool before the deleter, and the handler (finally with !success up to 10.6.0, catch on main) never names the pool; 8.8.2-8.11.4 by branch ancestry plus the executed 8.11.4. 10.6.0 still has the older handler, which releases only the write lock: #15675 is unreleased (on main and branch_11x, CHANGES 11.0.0 section), not on branch_10_6 or in 10.6.0.

Older leg, not the claimed repro: a failure inside the pre-enroll loop leaks the readers already pooled since LUCENE-6524 added the reader-commit branch (#7582, 2d0ac9bb7f2, 2015-06-28, first released in 5.3.0); a variant that injects an I/O error on the second segment fails on 5.3.0, 7.3.1, 7.4.0 and main. So LUCENE-8260 is the origin of the claimed trigger, not of every leg.

Upstream main f815e679bf6 (2026-11-01), branch_11x and branch_10_6 are unchanged at the catch and the loop.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails through gradle and the standalone JUnit runner, assertions on and off
10.6.0, 9.12.3 affectedthe minimal test fails on the release jars, assertions on and off
10.5.2 affectedthe minimal test fails on the release jars (assertions off)
9.0.0, 8.11.4 affectedthe same test with the pre-9.1 import (org.apache.lucene.util.LuceneTestCase) fails on the release jars (assertions off)
7.4.0, branch_7x 1bf56037d17 affectedthe same test fails, assertions on and off (first release; the LUCENE-8260 backport built from source)
7.3.1, branch_7x 4cd0a0aae32 not affectedthe same test passes, assertions on and off (the deleter ran before the pre-enroll loop)
main 645d4fe14a20 + fix fixthe minimal test passes through gradle and the standalone JUnit runner, assertions on and off

Independent checks

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

Checks and verdicts305 words

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

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedThe minimal test fails on main (assertions on and off), 10.6.0, 10.5.2 and 9.12.3 and passes with the fix; the matrix fails 5 of 8 on stock and passes 8 of 8 with the fix.
Independent reproducer realIts own test, with a core-closed listener as oracle, fails on main (assertions on and off), 10.5.2 and 9.12.3; the listCommits control passes; a 3-line catch-only fix makes it pass; a default-policy I/O-error variant leaks too.
refute review (OpenAI GPT-5.6) real, highExecuted on main ea57360912ed (the involved classes are byte-identical). Its two-segment test leaks {_1.cfs=1, _0.cfs=1} with assertions on and off, while a listCommits control and a successful writer-from-reader control pass; the fix passes it and 19 upstream tests.
intended review (OpenAI GPT-5.6) real, highExecuted. Its own test sees one leaked _0.cfs with assertions on and off; its expected-cleanup test fails on stock and passes with the fix; intended argument weak.
Intended-behaviour devil's advocate real, highRe-ran the test on main and on the 10.6.0 jars; the case for intended fails; its OS-level probe saw MMapDirectory keep 3 mappings of the deleted _0.cfs after 5 garbage collections.
adjudication confirmed, highRe-ran both tests on main; rated the impact minor and the priority Low.
credibility audit confirmed (Low)The audit re-ran the minimal test with a new seed (C0FFEE12), assertions on and off: fails the same way.
this doc (gradle) reproduced; fix validatedStock fails with assertions on and off and a second seed; both fix options pass; 222 closest-suite tests pass on stock and with each fix.

Diagram file

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

Sources and provenance

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

Finding record

LFV-1065, snapshot of 2026-11-06

Registry title
An IndexWriter opened from a reader's IndexCommit whose constructor then fails (for example the IndexDeletionPolicy's onInit throws, or an I/O error while the deleter reads a commit) never releases the segment readers its ReaderPool pre-enrolled from that reader, so the reader's segment files stay open after the caller closes the reader
Area
core / IndexWriter constructor failure path (reader-commit branch: IndexWriterConfig#setIndexCommit(reader.getIndexCommit()), ReaderPool pre-enrollment; LUCENE-6524, LUCENE-8260)
Priority
Low, rank 1090: When an IndexWriter opened from a reader's commit fails in its constructor, one reference per segment of that reader is never released, so the reader's files stay open, in every release since 7.4.0. It is a bounded leak after a loud failure on an opt-in Expert path, so it is Low.
Impact class
minor
Impact note
Applications that open a writer from a reader's commit (the documented Expert path IndexWriterConfig#setIndexCommit(reader.getIndexCommit()), used for example to roll back to or resume from an open NRT or non-NRT reader) and whose writer constructor then fails after the ReaderPool is built (an application IndexDeletionPolicy whose onInit throws the IOException it declares, an I/O error while IndexFileDeleter reads a segments_N, or a failure inside the pre-enroll loop): the constructor throws loudly and releases the write lock, but one reference per segment on the reader's segment cores is never released by Lucene and cannot be released through any public API, so the reader's segment files stay open after the reader is closed (executed: MockDirectoryWrapper reports {_0.cfs=1} still open; one more reference per segment on every failed attempt, still held after a successful retry); no wrong results, no data loss, any index size.
Summary
You open a DirectoryReader, pass its getIndexCommit() to IndexWriterConfig#setIndexCommit, and the IndexWriter constructor then fails, here because your IndexDeletionPolicy's onInit throws an IOException; you expect the exception and nothing left behind once you close the reader, as the constructor's own catch intends (it releases the write lock and, since #15675, the merge scheduler) and as the same failure with a commit from DirectoryReader.listCommits behaves. Instead the constructor has already built the ReaderPool, which pre-enrolled one SegmentReader per segment, each holding a reference on the reader's segment core, and its catch never closes the pool, so after reader.close() MockDirectoryWrapper still reports the segment's compound file open.
Introduced
8975692953713923bd1cc67766cf92565183c2b8 (master, 'LUCENE-8260: Extract ReaderPool from IndexWriter'; LUCENE-8260 = #9307, JIRA era, no apache/lucene PR) and its branch_7x backport 1bf56037d17bc4addd63dcca4a931647130168c6, which reached 7.4.0 [exec: lucene-core built from source at the backport fails the repro port with -ea and -da, its parent 4cd0a0aae32 passes both]. A regression for the claimed trigger, a side effect of the refactor: it moved the pre-enroll loop into the new ReaderPool constructor, which IndexWriter calls before it creates the IndexFileDeleter, while the constructor's failure handler kept releasing only the write lock; the commit message describes only the extraction. Older leg, not the claimed repro: a failure inside the pre-enroll loop itself (for example an I/O error while a later segment's pooled reader reads its field infos) leaks the readers already pooled since the reader-commit branch was added by LUCENE-6524 (#7582; 2d0ac9bb7f2 trunk, 7ab3ca7d608 branch_5x, 2015-06-28, first released in 5.3.0) [exec: follow-up-only variant fails on 5.3.0, 7.3.1, 7.4.0 and main]. (2018-04-23 (author date 2018-04-23T10:29:10+02:00 of 8975692953 and of the backport 1bf56037d17); first release 7.4.0 tagged 2018-06-22; older in-loop leg 2015-06-28 (LUCENE-6524), first release 5.3.0)
First affected version
7.4.0 [exec] (-ea and -da; the LUCENE-8260 backport); 7.3.1 passes [exec, -ea and -da] because the pre-enroll ran after the IndexFileDeleter there; 5.2.1 and older have no reader-commit branch. The older in-loop leg is in every release from 5.3.0 [exec, follow-up-only variant]
Affected versions
every release from 7.4.0 through 10.6.0 and main 645d4fe14a20; never fixed. [exec] the minimal JUnit TestIndexWriterFromReaderFailedOpen fails on main (-ea and -da; re-run 2026-11-01, seed DEADBEEF, 'MockDirectoryWrapper: cannot close: there are still 1 open files: {_0.cfs=1}'), on the released 10.6.0 jars (4ae946e5a9b, -ea and -da), 10.5.2 (-da) and 9.12.3 (-ea and -da); a port that differs only in the LuceneTestCase import fails on 9.0.0, 8.11.4 (-da), 7.4.0 and the branch_7x backport 1bf56037d17 (-ea and -da), and passes on 7.3.1 and the backport's parent 4cd0a0aae32 (-ea and -da); the independent reproduction's independent test fails on main (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3 (-da). [read] in every local release tag from 7.4.0 to 8.8.1 and 9.0.0 to 10.6.0 the constructor builds the ReaderPool before the IndexFileDeleter and its failure handler (finally !success up to 10.6.0, catch on main) never names the pool; 8.8.2-8.11.4 by branch ancestry plus the executed 8.11.4. Upstream main f815e679bf6 (2026-11-01), branch_11x and branch_10_6 are unchanged at the fix sites. Not new in 10.6.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (2026-11-01, on top of the finder's scripted prior-report search and the independent reproduction's, the intended-behaviour check's and the adjudication's GitHub searches): three scripted prior-report searches (52, 51 and 24 queries; 580, 1359 and 2119 hits) over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 70 manual GitHub searches over issues and PRs in all states; the changed files of all 409 open apache/lucene PRs and the diffs of the 18 that touch IndexWriter.java or ReaderPool.java (none changes the constructor catch or the pre-enroll loop's exception handling); every item updated in the last 48 h; the upstream history of the fix-site files with author dates. Refresh 2026-11-01: no new item; the last commits on IndexWriter.java, ReaderPool.java and IndexFileDeleter.java on main are still #16581 (2026-09-21), #16570 (2026-09-01) and #15105 (2025-08-21). Related, not this bug: #14971 / #15675 (merged 2026-02-22; the same catch leaked the merge scheduler's thread pool on init failure and the fix added config.getMergeScheduler() plus an upstream test; it never mentions the reader pool: a sibling resource at the same site and the precedent), #9307 (LUCENE-8260, origin of the ordering, no discussion of exceptions), #7582 (LUCENE-6524, origin of the reader-commit branch), #3550 (LUCENE-2476), #4734 (LUCENE-3660), #1169 (LUCENE-91), #1790 (LUCENE-715) (write-lock-only constructor leaks), #6718 (LUCENE-5656, CFS handles on another path), #16625 / open PR #16643 (rollback deletes files of pooled readers; other site, opposite symptom), open PRs #16948, #16847, #16888, #16923 (touch the same files, not the catch).
Lucene commit tested
main 645d4fe14a20: the minimal test through gradle in a scratch worktree (2026-11-02), stock and with both fix options, assertions on and off and a second seed, plus 222 tests of the closest suites stock and with each fix; the minimal test through the standalone JUnit runner with -ea and -da (finder, registration check, audit with a new seed). Release jars: 10.6.0, 10.5.2 and 9.12.3 (the minimal test); 9.0.0, 8.11.4, 7.4.0 and 7.3.1 (the same test with the pre-9.1 test-framework import, org.apache.lucene.util.LuceneTestCase); branch_7x 1bf56037d17 and its parent 4cd0a0aae32 built from source. Upstream main f815e679bf6 (2026-11-01), branch_11x and branch_10_6 are unchanged at the fix sites (read).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1148-1158 (at 645d4fe14a20) (readerPool = new ReaderPool(..., reader, config): pre-enrolls the reader's segments), :1165-1182 (deleter = new IndexFileDeleter(...), created after the pool), :1202-1208 (catch (Throwable t): 'init: hit exception on init; releasing write lock', IOUtils.closeWhileSuppressingExceptions(t, config.getMergeScheduler(), writeLock); the pool is not closed)
  • lucene/core/src/java/org/apache/lucene/index/ReaderPool.java:90-115 (at 645d4fe14a20) ('Pre-enroll all segment readers into the reader pool': one new SegmentReader per leaf sharing the reader's core, put into readerMap; no cleanup if a later iteration throws)
  • lucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:161 (at 645d4fe14a20) (reads every segments_N), :237 (policy.onInit)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:234-241 (at 645d4fe14a20) (setIndexCommit: 'This can also be used to open IndexWriter from a near-real-time reader, if you pass the reader's DirectoryReader#getIndexCommit')
  • lucene/core/src/java/org/apache/lucene/index/IndexDeletionPolicy.java:62 (at 645d4fe14a20) ('public abstract void onInit(List<? extends IndexCommit> commits) throws IOException')
  • lucene/core/src/test/org/apache/lucene/index/TestConcurrentMergeScheduler.java:966-987 (at 645d4fe14a20) (testMergeSchedulerClosedOnIndexWriterInitFailure, GITHUB#14971, added by #15675: 'MergeScheduler should be closed on IndexWriter init failure')
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:403, (at 645d4fe14a20) :947, :954-956, :982-990, :1148-1158, :1166-1182, :1202-1209
  • lucene/core/src/java/org/apache/lucene/index/ReaderPool.java:90-115 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/SegmentReader.java:124-160; (at 645d4fe14a20) SegmentCoreReaders.java:83, :167-179
  • lucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:161, (at 645d4fe14a20) :237
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:571-574, (at 645d4fe14a20) :595, :644-646; DirectoryReader.java:456, :481; IndexWriterConfig.java:119, :126-132
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:234-241 (at 645d4fe14a20)
  • lucene/CHANGES.txt:10312-10314 (at 645d4fe14a20) (LUCENE-6524, 5.3.0)
  • lucene/test-framework/src/java/org/apache/lucene/tests/index/RandomIndexWriter.java:96-125 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:155-161; (at 645d4fe14a20) store/MMapDirectory.java:124-139, :192, :411-413 (default GROUP_BY_SEGMENT, per-segment RefCountedSharedArena); store/RefCountedSharedArena.java:74
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:941-943, (at 645d4fe14a20) :1202-1209
  • lucene/core/src/java/org/apache/lucene/index/IndexDeletionPolicy.java:46-62; (at 645d4fe14a20) index/DirectoryReader.java:644-649
  • lucene/core/src/test/org/apache/lucene/index/TestConcurrentMergeScheduler.java:965-987 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterFromReader.java:87, (at 645d4fe14a20) :380
  • lucene/test-framework/src/java/org/apache/lucene/tests/index/RandomIndexWriter.java:96-125 (at 645d4fe14a20)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/ReaderPool.java:45, (at 645d4fe14a20) :233-237, :314-341; util/IOUtils.java:97-106
History
  • a Lucene clone: git show releases/lucene/10.6.0:lucene/core/src/java/org/apache/lucene/index/IndexWriter.java (:1154 ReaderPool, :1173 IndexFileDeleter, :1213-1215 handler releases only the write lock)
  • lucene/CHANGES.txt:317-318 (at 645d4fe14a20) (GITHUB#14971 in the 11.0.0 section)
  • read-only GitHub API GETs (2026-11-02): branch_11x IndexWriter.java:1206 closes config.getMergeScheduler() and writeLock; branch_10_6 IndexWriter.java:1215 releases only writeLock

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

Repro files

About the diagrams

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

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.