- A
DirectoryReaderhas 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. IndexWriterConfig#setIndexCommit(reader.getIndexCommit())(Expert) opens a writer at exactly the point an open reader sees, NRT or not.- 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.
_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.- Index 1 document and close the writer: one segment, stored as the compound file
_0.cfs. OpenDirectoryReader ron it. - Open a writer from the reader's commit,
new IndexWriterConfig().setIndexCommit(r.getIndexCommit()), with a deletion policy whoseonInitthrowsIOException. The constructor throws, as it should. - You expect nothing left behind: you hold only
r, so afterr.close()the directory should close cleanly, as it does when the commit comes fromDirectoryReader.listCommits. - Instead
_0.cfsstays 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 itsReaderPool, each holding a reference on the segment's core; itscatchreleases the write lock and the merge scheduler, not the pool. - 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.
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.
- Segment core (
SegmentCoreReaders): the per-segment open files and readers shared by everySegmentReaderof 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 aReaderCommitthat carries the reader (StandardDirectoryReader.java:571-574,:644-646); a commit fromlistCommitsis aReaderCommitbuilt with anullreader (DirectoryReader.java:456,:481; field set atStandardDirectoryReader.java:595), so itsgetReader()returnsnull. - Pre-enrollment: the
ReaderPoolconstructor's loop that pools oneSegmentReaderper 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 constructor takes the write lock (
IndexWriter.java:954), enterstry(:956) and gets the commit's reader,reader = commit.getReader()(:982-990). readerPool = new ReaderPool(..., reader, config)(:1148-1158) pre-enrolls oneSegmentReaderper segment, each adding a reference to the reader's core (ReaderPool.java:96-113).- Only then
deleter = new IndexFileDeleter(...)(IndexWriter.java:1166-1182) reads every commit (IndexFileDeleter.java:161) and callspolicy.onInit(commits)(:237). - If that throws,
catch (Throwable t)(IndexWriter.java:1202-1209), "init: hit exception on init; releasing write lock", runsIOUtils.closeWhileSuppressingExceptions(t, config.getMergeScheduler(), writeLock)(:1206). The pool is not in that list, and it lives in aprivate finalfield (:403) of a writer the caller never receives. r.close()removes the reader's own reference, but the pooled reader's reference stays: the core never reaches 0 and_0.cfsstays 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".
| case | files open after the reader is closed, expected | actual |
|---|---|---|
1 segment, reader commit, onInit throws | 0 | 1 {_0.cfs=1} |
2 segments, reader commit, onInit throws | 0 | 2 {_1.cfs=1, _0.cfs=1} |
| 1 segment, then a successful retry writer, closed | 0 | 1 {_0.cfs=1} |
same policy, commit from listCommits | 0 | 0 |
| same policy, no commit set | 0 | 0 |
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 ownRandomIndexWritertakes 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 fromlistCommits, or none, is not affected. - What triggers it. An application
IndexDeletionPolicywhoseonInitthrows theIOExceptionit declares (none of the stock policies throws there); an I/O error whileIndexFileDeleterreads asegments_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 5System.gc()calls ran. WithMMapDirectory, whichFSDirectory.openreturns 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). WithNIOFSDirectorythe 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.
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.
/*
* 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();
}
}
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.cfsopened by the segment core), assertions on and off, on main and 10.6.0.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on 10.6.0, 10.5.2 and 9.12.3 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# 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- The constructor's
catchexists to release what it acquired: the write lock and, since #15675, the merge scheduler ("MergeScheduler should be closed on IndexWriter init failure"). onInitdeclaresthrows IOException; an I/O error while reading a commit needs no user code.- Lucene's own
RandomIndexWriteropens writers from reader commits and, if the constructor fails, closes only the reader.
The full argument254 words
- The
catchis 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 plusTestConcurrentMergeScheduler#testMergeSchedulerClosedOnIndexWriterInitFailure(:965-987), whose trigger is itself a user error (OpenMode.APPENDon an empty directory). The pool is the one resource holding open files that thecatchstill misses (theDocumentsWriterand a half-builtIndexFileDeleterare 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.
RandomIndexWriteropens a writer fromreader.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 (testOnClosedReaderfails atreader.leaves()inside theReaderPoolconstructor,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
IndexFileDeleterfirst and pre-enrolled after it, so anonInitor deleter failure found an empty pool (7.3.1 passes the test). LUCENE-8260 (#9307, "Extract ReaderPool from IndexWriter", 2018) moved the loop into theReaderPoolconstructor, 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.
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
onInittrigger (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.putitself throws, in practice only a VM error such asOutOfMemoryError, after the current reader'sReadersAndUpdateswas built, that reader is not yet in the map anddropAll()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=trueand=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.
spotlessCheckwas 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).
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.
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.
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.
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),MockDirectoryWrapperI/O failures scoped toIndexFileDeleter.<init>andReaderPool.<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.
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).
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA; the web | 2026-11-01 | three 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-01 | changed 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 hours | none changes the catch or the loop's exception handling (#16948, #16847, #16888, #16923 touch the same files) |
| apache/lucene main, branch_11x, branch_10_6 | 2026-11-01 | refresh: last commits on the fix-site files by author date | nothing new; last changes #16581 (2026-09-21), #16570 (2026-09-01), #15105 (2025-08-21) |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails through gradle and the standalone JUnit runner, assertions on and off |
| 10.6.0, 9.12.3 | affected | the minimal test fails on the release jars, assertions on and off |
| 10.5.2 | affected | the minimal test fails on the release jars (assertions off) |
| 9.0.0, 8.11.4 | affected | the 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 | affected | the same test fails, assertions on and off (first release; the LUCENE-8260 backport built from source) |
7.3.1, branch_7x 4cd0a0aae32 | not affected | the same test passes, assertions on and off (the deleter ran before the pre-enroll loop) |
main 645d4fe14a20 + fix | fix | the minimal test passes through gradle and the standalone JUnit runner, assertions on and off |
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).
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | The 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 | real | Its 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, high | Executed 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, high | Executed. 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, high | Re-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, high | Re-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 validated | Stock 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 412 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,523 words
- 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-eaand-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_7x1bf56037d17and its parent4cd0a0aae32built from source. Upstream mainf815e679bf6(2026-11-01), branch_11x and branch_10_6 are unchanged at the fix sites (read).
- 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-1209lucene/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-179lucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:161, (at 645d4fe14a20) :237lucene/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-1209lucene/core/src/java/org/apache/lucene/index/IndexDeletionPolicy.java:46-62; (at 645d4fe14a20) index/DirectoryReader.java:644-649lucene/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) :380lucene/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
- TestIndexWriterFromReaderFailedOpen.java (one test, public API):
repro/LFV-1065/TestIndexWriterFromReaderFailedOpen.java(53 lines)
Made from the code at 645d4fe14a20 and the executed runs.
assets/LFV-1065/bug.png(412 KB)
