The refresh takes its file reference (deleter.incRef) at the start of commitMerge, before the deletes made during the merge are carried over. commitMerge then drops the 100%-deleted segment but skips its referenced files. finishGetReaderMerge closes the unused reader and clears the map without decRef, so the cleanup that would release it finds nothing.
- Opening or refreshing a near-real-time reader flushes, then (by default since 9.3) asks the merge policy to merge tiny segments and waits up to 500 ms, so the new reader sees fewer segments.
IndexWritercounts references per index file and deletes a file when its count drops to zero.- When a refresh merge finishes, the refresh opens a reader on the merged segment and takes a reference on its files, so that nothing deletes them before the new reader uses them.
- If every document of a merge was deleted while it ran,
IndexWriterdrops the merged segment instead of adding it, and deletes its files.
_0 and _1 into _2 and takes a reference on _2's files; both documents are deleted during the merge, so IndexWriter drops _2 but deletes none of its referenced files; the unused reader is closed without releasing the reference, and _2's files outlive close(). Select the still to open it at full size. Generated with AI, checked against the code.- Open an
IndexWriterwith the default config apart from the merge policy and a serial merge scheduler (so merge-on-refresh is on, 500 ms wait) and flush two one-document segments,_0and_1. - Refresh with
DirectoryReader.open(writer): it merges_0and_1into_2. While that merge runs, both documents are deleted (in the test by a delete plusflush()from a merge hook; in real use by another thread's update and commit). IndexWritersees that_2is 100% deleted and drops it. You expect its files to be deleted, as they are whenforceMergedrops the same segment.- Instead the refresh had already taken a reference on
_2's files for the new reader, and it closes that unused reader without releasing the reference. Afterw.close()all 10 files of_2are still on disk and no commit references them; only the nextIndexWriterdeletes them. - No hook is needed: a tight default-config loop of updates, commits and refreshes leaked 675 merged segments (2,025 files) in 4 s; with the fix, none.
Applications on 9.3.0 or later with the default IndexWriterConfig that refresh NRT readers while their own commits or flushes apply updates or deletes (8.7.0 to 9.2.0: only with merge-on-refresh turned on). Each hit leaks one small merged segment until the writer is reopened. Disk only; results stay correct; real-workload rates were not measured.
Mechanism, terms and numbers675 words
Merge-on-refresh (LUCENE-8962, on by default since 9.3.0) makes a near-real-time refresh wait up to 500 ms for merges of small segments. When such a merge completes, the refresh opens a reader on the merged segment and takes a reference on its files (deleter.incRef), at the start of commitMerge and before the deletes made during the merge are carried over. If those deletes removed every document, commitMerge drops the merged segment, and its file deletion skips the referenced files. finishGetReaderMerge then closes the unused reader and clears mergedReaders without decRef, so the cleanup that pairs close with decRef finds nothing. The files keep their reference for the rest of the writer's life: deleteUnusedFiles(), commit(), forceMerge, close() and rollback() leave them, and only the next IndexWriter open deletes them. Searches, commits and CheckIndex stay correct; it is a disk-space and file-count leak, one small merged segment per hit. A one-test repro fails on main 645d4fe14a20, 10.6.0, 10.5.2, 9.12.3 and 9.3.0; deleting the two offending lines fixes it.
// getReader with merge-on-refresh (:619-648): the callback for each completed refresh merge
sci -> {
SegmentReader apply = readerFactory.apply(sci);
mergedReaders.put(sci.info.name, apply); // :643
deleter.incRef(sci.files()); // :647, the reference
}
// onGetReaderMergeResources (:649-666), run at the end of getReader (:723) or on error (:716):
// for each reader still in mergedReaders: deleter.decRef(its files), then close it (:658-664)
// finishGetReaderMerge (:727-753)
StandardDirectoryReader reader = maybeReopenMergedNRTReader(...);
IOUtils.close(mergedReaders.values()); // :749, closes the leftover readers ...
mergedReaders.clear(); // :750, ... and forgets them: no decRef
return reader;DirectoryReader.open(w)runs getReader. WithmaxFullFlushMergeWaitMillis > 0(500 by default) it prepares point-in-time merges forMergeTrigger.GET_READER(:619-648), thenfinishGetReaderMergeruns the merge and waits for it (:738-739).- The merge writes
_2.commitMergestarts withmerge.onMergeComplete()(:4894-4896). The wrapper'sonMergeComplete(:4031-4045) has three conditions (:4034-4037); its only check against a dropped segment isinfo.info.maxDoc() > 0 /* never do this if the segment is dropped / empty */(:4036-4037), and the other two (collection not stopped, merge not aborted) hold here._2hasmaxDoc2, so the callback runs: reader opened, put inmergedReaders,incRefof_2's files. commitMergecarries over the deletes made during the merge (:4936-4937), finds the segmentallDeleted(:4946-4949) and drops it withreaderPool.dropanddeleteNewFiles(:5002-5007).deleteNewFilesdeletes only files without a reference (IndexFileDeleter.java:633-636,FileDeleter.java:154-156, 173-191), so_2's files survive.- The wrapper's
mergeFinishedapplies a merge to the point-in-timeSegmentInfosonly whensegmentDropped == false(:3978-3980).maybeReopenMergedNRTReaderremoves a reader frommergedReaders, and laterdecRefs its files, only when its segment is in thoseSegmentInfos(:772,:780,:787-789)._2is not, so its reader stays in the map. finishGetReaderMergecloses it and clears the map (:749-750).onGetReaderMergeResources.close()at the end of getReader (:723) finds nothing to release._2's files keep a reference count of 1 for the rest of the writer's life.IndexFileDeleter.refresh(), whichrollback()uses, also skips referenced files (IndexFileDeleter.java:455-486,:467). The nextIndexWriterstarts with fresh counts and deletes them as unreferenced.
| what happens in the same writer | the dropped merged segment's files afterwards |
|---|---|
| refresh merge, all its documents deleted while it runs (the bug) | kept through deleteUnusedFiles(), commit(), forceMerge, close(), rollback(); the next writer deletes them |
the same drop through forceMerge or maybeMerge | deleted (no callback, no reference) |
| the same drop through merge-on-commit | deleted: it takes its reference in mergeFinished, only when the merge is applied (:3978-3995) |
| the same refresh merge without deletes | the segment is kept and used; nothing is left over |
The point-in-time reader itself is correct: it keeps _0 and _1 as they were when the refresh started, because deletes made during the merge belong to the next refresh (:3940-3948, javadoc of preparePointInTimeMerge).
Exposure and likelihood287 words
- Who. Applications on 9.3.0 or later with the default
IndexWriterConfigthat refresh NRT readers (DirectoryReader.openIfChanged,SearcherManager.maybeRefresh) while their own commits or flushes apply updates or deletes. 8.7.0 to 9.2.0: only withsetMaxFullFlushMergeWaitMillis > 0and a merge policy that returns full-flush merges. - Settings. The default
TieredMergePolicyproposes full-flush merges of segments each below its 16 MB floor (MergePolicy.java:689-714,TieredMergePolicy.java:79, 187-189); the default wait is 500 ms (IndexWriterConfig.java:108). - Trigger. Every document of a refresh merge's input segments must be deleted, and the deletes applied, while that merge runs. That favours workloads that keep rewriting the same documents.
- Effect. One small merged segment's files per hit, kept through
deleteUnusedFiles(),commit(),forceMerge,close()androllback(); the nextIndexWriteropen deletes them. In one probe a leaked segment was 3 files, 2,378 bytes. The deleted documents' bytes stay in those files until then (raw file access only). No premature delete, no wrong result, no corruption, CheckIndex clean, no open file handle afterclose(). - How often. Timing-dependent and not measured on real workloads. A tight default-config loop (one thread updating 2 ids, one committing continuously, refreshes for 4 s) leaked 675 merged segments; a milder loop (a commit every 2 updates, 5 s) leaked 1 to 13 merged segments per run on 2026-10-26 and none in 5 runs on 2026-10-31.
- Likelihood: plausible. No fault is needed, but the window is narrow.
- Workaround until fixed (by reading, not run on main):
setMaxFullFlushMergeWaitMillis(0)turns off full-flush merges ("Set to 0 to disable merging on full flush"; this also turns off merge-on-commit), or reopen theIndexWriterfrom time to time. 9.2.0, where the wait is 0 by default, passes the minimal test.
One JUnit test in org.apache.lucene.index, public API only: the default config with SerialMergeScheduler and a merge policy that proposes one refresh merge, whose wrapForMerge hook deletes both documents while the merge runs (a deterministic stand-in for another thread's commit). It then counts the files outside the last commit after close().
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.index;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestGetReaderMergeDroppedSegmentLeak extends LuceneTestCase {
public void testFullyDeletedGetReaderMergeLeavesNoFiles() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
IndexWriterConfig iwc =
new IndexWriterConfig()
.setMergePolicy(NoMergePolicy.INSTANCE)
.setMergeScheduler(new SerialMergeScheduler());
IndexWriter w = new IndexWriter(dir, iwc);
for (int i = 0; i < 2; i++) {
w.addDocument(List.of(new StringField("f", "x", Field.Store.NO)));
w.flush();
}
w.getConfig()
.setMergePolicy(
new FilterMergePolicy(NoMergePolicy.INSTANCE) {
@Override
public MergeSpecification findFullFlushMerges(
MergeTrigger trigger, SegmentInfos infos, MergeContext context) {
if (trigger != MergeTrigger.GET_READER) {
return null;
}
MergeSpecification spec = new MergeSpecification();
spec.add(
new OneMerge(infos.asList()) {
@Override
public CodecReader wrapForMerge(CodecReader reader) throws IOException {
w.deleteDocuments(new Term("f", "x"));
w.flush();
return reader;
}
});
return spec;
}
});
DirectoryReader.open(w).close();
w.close();
Set<String> leftover = new TreeSet<>(Arrays.asList(dir.listAll()));
leftover.removeAll(SegmentInfos.readLatestCommit(dir).files(true));
// expected: no files outside the last commit after close(); actual: the dropped merged segment's files
assertEquals(Set.of(), leftover);
}
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestGetReaderMergeDroppedSegmentLeak -Ptests.seed=DEADBEEF -Ptests.asserts=true
- Expected
- No file outside the last commit after
DirectoryReader.open(w).close(); w.close(). - Observed
AssertionError: expected:<[]> but was:<[_2.fdm, _2.fdt, _2.fdx, _2.fnm, _2.si, _2_Lucene104_0.doc, ..., _2_Lucene104_0.tmd]>: the dropped merged segment's 10 files, with assertions on and off.
More files, environment, full output359 words
One test class, one method, single thread: under SerialMergeScheduler the refresh merge runs in the refreshing thread, so the order is fixed. Public API only, ByteBuffersDirectory (no mock filesystem, no lock file), about 1 s. Merge-on-refresh comes from the default config: the test sets only the merge policy and the scheduler, and the 500 ms wait is the default. It fails with any seed (10 of 10 random-seed iterations).
Do not use deleteDocuments(MatchAllDocsQuery) in the hook: IndexWriter turns it into deleteAll(), which waits for the running merge, so the test deadlocks itself (test-side only).
Formatting: google-java-format (./gradlew tidy) wraps the comment on line 69 into two lines; after that the test passes core's format, ECJ and forbidden-apis checks and still fails on stock main.
Regression test: this same test, as its own class or as a method of TestIndexWriterMergePolicy, which holds the upstream merge-on-getReader tests.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT), 10.6.0, 10.5.2: fail; 9.12.3, 9.3.0: fail (assertions on); 9.2.0: passes (merge-on-refresh off by default); with either fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run) |
cp $REPRO/TestGetReaderMergeDroppedSegmentLeak.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestGetReaderMergeDroppedSegmentLeak -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false and with other seeds
# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (identical with false)
TestGetReaderMergeDroppedSegmentLeak > testFullyDeletedGetReaderMergeLeavesNoFiles FAILED
java.lang.AssertionError: expected:<[]> but was:<[_2.fdm, _2.fdt, _2.fdx, _2.fnm, _2.si, _2_Lucene104_0.doc, _2_Lucene104_0.psm, _2_Lucene104_0.tim, _2_Lucene104_0.tip, _2_Lucene104_0.tmd]>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:CB15C06EE4D5EEF7]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.failNotEquals(Assert.java:835)
at org.junit.Assert.assertEquals(Assert.java:120)
at org.junit.Assert.assertEquals(Assert.java:146)
at org.apache.lucene.index.TestGetReaderMergeDroppedSegmentLeak.testFullyDeletedGetReaderMergeLeavesNoFiles(TestGetReaderMergeDroppedSegmentLeak.java:70)
NOTE: test environment is: codec=Asserting(Lucene104): {}, ...
1 test completed, 1 failed
# releases, seed DEADBEEF: the same assertion (file names follow the randomized test codec)
10.6.0 -ea/-da, 10.5.2 -ea/-da: [_2.fdm, _2.fdt, _2.fdx, _2.fnm, _2.si, _2_Lucene104_0.doc, ..., _2_Lucene104_0.tmd]
9.12.3 -ea: [_2.fdm, _2.fdt, _2.fdx, _2.fnm, _2.si, _2_Lucene912_0.doc, _2_Lucene912_0.psm, _2_Lucene912_0.tim, _2_Lucene912_0.tip, _2_Lucene912_0.tmd]
9.3.0 -ea: [_2.fdm, _2.fdt, _2.fdx, _2.fnm, _2.si, _2_Lucene90_0.doc, _2_Lucene90_0.tim, _2_Lucene90_0.tip, _2_Lucene90_0.tmd]
9.2.0 -ea: PASS (no merge on refresh by default)
# with either fix: 1 test completed, 0 failed (assertions on and off)
# default config, no hook, 4 s timing loop (not the repro), assertions off
stock: refreshes=2422 leakedFiles=2025 leakedSegments=675
fix v2: refreshes=2269 leakedFiles=0 leakedSegments=0
recommended: refreshes=2316 leakedFiles=0 leakedSegments=0- The code's own cleanup,
onGetReaderMergeResources, pairs each close of a leftover reader with adecRef;finishGetReaderMergeempties the map before it runs. rollback()promises to remove "any temporary files that had been created",deleteUnusedFiles()"any index files that are no longer used"; neither does here.- The same drop through
forceMergeor merge-on-commit deletes the files.
The full argument238 words
- The code's own cleanup releases the reference.
onGetReaderMergeResources"closes the merged readers and decrements the files references ... for readers that haven't been removed from the mergedReaders and release elsewhere" and doesdecRefthen close for each (IndexWriter.java:651-664);maybeReopenMergedNRTReaderdecRefs the readers it consumes (:787-789). OnlyfinishGetReaderMergecloses withoutdecRef, and by clearing the map it hides the leftovers from the cleanup. - The guard shows the intent. The callback is skipped for segments that are "dropped / empty" (
:4036-4037); it misses only a segment that becomes 100% deleted later in the samecommitMerge. - Public contracts.
rollback(): "This removes any temporary files that had been created, after which the state of the index will be the same as it was when commit() was last called" (:2567-2570).deleteUnusedFiles(): "Expert: remove any index files that are no longer used" (:6171). Internal: "When all the commits referencing a certain file have been deleted, the refcount for that file becomes zero, and the file is deleted" (IndexFileDeleter.java:47-50). - Lucene's tests treat leftover files as failures:
TestIndexWriter.assertNoUnreferencedFilesfails when a fresh writer would delete anything. - History. The feature author fixed the mirror-image problem on merge-on-commit before release (a premature delete of a "fully deleted and dropped" segment, #10005 comment of 2020-06-22). None of the 81 comments and reviews on lucene-solr#1623 discusses a dropped merged segment or leftover files.
The devil's-advocate check171 words
The strongest case for "works as designed": the next IndexWriter open cleans up, and Lucene tolerates unreferenced files elsewhere (MockDirectoryWrapper's check is "off by default; see LUCENE-5574"); LUCENE-8324 called a dropped segment's leftover files "not a massive issue"; the refcount promise is in an internal javadoc; deleting from wrapForMerge is an unusual hook; and four years passed without a CI report, although the test framework turns merge-on-refresh on.
Why it fails: LUCENE-5574 covers a reader that outlives its writer, while the repro closes every reader first, and next-open cleanup is the crash-recovery path. LUCENE-8324 was fixed anyway, "for correctness", which is the right framing here: real and minor. The public rollback() and deleteUnusedFiles() javadocs promise the deletion. The hook only makes the timing deterministic: a hook-free default-config loop leaks too, and the fix removes that leak. CI is silent because the unreferenced-files check is off by default and the merge-on-getReader tests do not delete all documents of a refresh merge while it runs. Verdict: not intended, a minor defect.
Recommended Leave the leftover readers to the existing cleanup
Delete the two lines IOUtils.close(mergedReaders.values()); mergedReaders.clear(); in finishGetReaderMerge. The leftover readers then stay in the map, and the existing onGetReaderMergeResources cleanup, run at the end of getReader, closes them and releases their files. With it the repro and all 1,396 core org.apache.lucene.index tests pass, assertions on and off.
// IndexWriter.finishGetReaderMerge, after maybeReopenMergedNRTReader(...):
- IOUtils.close(mergedReaders.values());
- mergedReaders.clear();
+ // Readers still in mergedReaders belong to merges that were not applied to the point-in-time
+ // SegmentInfos, e.g. a merged segment dropped as fully deleted. Leave them in the map:
+ // onGetReaderMergeResources closes them and releases their file references.
return reader;All options and trade-offs5 options ยท 711 words
Two fixes were built and validated through gradle in a scratch worktree of 645d4fe14a20; either is short. The recommended one reuses the cleanup the code already has; the alternative, the hunt's fix v2, releases the leftovers at once. The hunt's model checking covers releasing the leftovers in general, and its randomized runs used the hunt's first version (v1) of this fix, whose normal path is the same as v2's.
IndexWriter.finishGetReaderMerge, lines 749-750: delete the close-and-clear (-2/+3 lines with a comment).
// IndexWriter.finishGetReaderMerge, after maybeReopenMergedNRTReader(...):
- IOUtils.close(mergedReaders.values());
- mergedReaders.clear();
+ // Readers still in mergedReaders belong to merges that were not applied to the point-in-time
+ // SegmentInfos, e.g. a merged segment dropped as fully deleted. Leave them in the map:
+ // onGetReaderMergeResources closes them and releases their file references.
return reader;Trade-offs
mergedReadersis local to the getReader call, and nothing adds to it oncestopCollectingMergedReadersis set (:741).onGetReaderMergeResources.close()runs at the end of getReader (:723) or in the error path (:716) and doesdecRefplus close for each leftover (:658-664), exactly once. This matches that cleanup's own comment.- The leftover readers stay open a little longer, through the
maybeMerge(FULL_FLUSH)at:704-707(withSerialMergeSchedulerthat may run merges synchronously); the files are still released before getReader returns. - Not covered by the hunt's TLA+ model or its randomized multi-thread runs (those runs used the hunt's first version of the alternative).
What the checks validated
- Scratch worktree of
645d4fe14a20, gradle--offline, OpenJDK 25.0.3, seed DEADBEEF: the repro passes; all coreorg.apache.lucene.index.*tests (215 suites, 1,396 tests, 56 skipped, 0 failures) pass with assertions on and off; corecheckGoogleJavaFormat,ecjLintMainandforbiddenApisMainpass. - The 4 s default-config loop leaks 0 files (stock: 2,025).
Copy the leftovers out of mergedReaders, clear the map, then decRef each leftover's files and close it; if that throws, close the reopened reader too (+17/-1 lines).
List<SegmentReader> leftovers = new ArrayList<>(mergedReaders.values());
mergedReaders.clear();
try {
IOUtils.close(
leftovers.stream()
.flatMap(
sr ->
Stream.<Closeable>of(() -> deleter.decRef(sr.getSegmentInfo().files()), sr))
.toList());
} catch (Throwable t) {
IOUtils.closeWhileSuppressingExceptions(t, reader);
throw t;
}Trade-offs
Releases the files right away, but duplicates the cleanup logic. Its first version decRef'd before clearing the map, so an exception while closing could let onGetReaderMergeResources decRef the same files again (found by the cross-model review); v2 clears first.
What the checks validated
- Here: the repro,
TestIndexWriterMergePolicy,TestIndexWriterReaderandTestIndexFileDeleter(57 tests) and all coreorg.apache.lucene.index.*tests (215 suites, 1,396 tests) pass with assertions on and off; core's format, ECJ and forbidden-apis checks pass after./gradlew tidy, which joins theStream.ofcall onto one line; the 4 s loop leaks 0. - In the hunt: with the fix modelled (the leftovers' references released; the model does not encode v2's code) the TLA+ model holds every invariant (up to 71,975,928 states with 5 segment names); the hunt's first version (v1) of this fix, whose normal path is the same as v2's, passed 600 randomized multi-thread runs with 0 leaked and 0 missing files (stock: 21 of 150 leaked); with v2 itself, 8 upstream test classes pass and the hook-free loops leak 0.
As merge-on-commit does, in mergeFinished when segmentDropped == false.
Trade-offs
The refresh must open the merged reader before the merge's own deletes are carried over, to keep point-in-time semantics (:3940-3948), so the reader and its files must be held from onMergeComplete on (by reading).
onGetReaderMergeResources (and v2) decRef first, so a released file is deleted while its reader is still open: 6 delete-while-open attempts in a probe with the fix.
Trade-offs
Harmless on POSIX; where the OS refuses to delete an open file, FSDirectory keeps a pending delete and retries later. Not run.
setMaxFullFlushMergeWaitMillis(0) turns off full-flush merges (on refresh and on commit), or reopen the IndexWriter from time to time to reclaim the files.
Trade-offs
By reading, not run on main; loses the merge-on-refresh benefit.
Elasticsearch turns merge-on-refresh off and Solr leaves it opt-in (read in their sources, not run), so the exposure is mainly applications that use Lucene's default IndexWriterConfig or turn the feature on.
While writing a TLA+ model of how IndexWriter counts file references, the finder noticed that the refresh's incRef has no matching decRef when the merged segment is dropped. The TLC model checker then found a 9-state trace ending with the files still referenced after rollback(), and a public-API test reproduced it on the real code.
The method in full183 words
Method: semi-formal (a code-reading hypothesis during modelling, witnessed by a model checker, then executed).
- A hunt unit modelled the
IndexFileDeleterreference-count protocol in TLA+ (IFDRefcount.tla): flushes, commits, merges, merge-on-commit, merge-on-refresh, NRT readers and rollback. - While writing the merge-on-refresh part, the finder saw that the callback's
incRefhas nodecRefon the dropped-segment path. TLC confirmed it: invariant RcExact is violated in an 8-state trace and NoLeakAtQuiescence in a 9-state trace (flush, flush, refresh with a refresh merge, the merge writes, all its documents are deleted,commitMergedrops it, the refresh finishes, rollback: the merged segment's files keep a reference). With one thread (no deletes during the merge) the model is clean (1,572,782 states); with the fix every invariant holds (up to 71,975,928 states). The stock model never deletes a referenced file (up to 5,705,664 states). - The finder then replayed the trace as a public-API JUnit test on the real jars and measured a hook-free default-config loop. Independent reproducers, cross-model reviews (OpenAI GPT-5.6, reading only), an adjudication, an audit, the version-history runs, a prior-report search, a repro-minimization step and this doc followed.
No prior report found (2026-10-26: 96 tool queries over GitHub, the JIRA archive, mailing lists and the web, 12 direct GitHub searches, every comment on the introducing PR and on LUCENE-8962, the files of all 409 open PRs; re-checked 2026-10-31). The nearest items are already-fixed siblings at other fix sites, such as LUCENE-8324.
Searches3 searches
Verdict new (confidence medium-high). Nearest, not this bug: the 2020 merge-on-commit dropped-segment fix during LUCENE-8962 (#10005 comment of 2020-06-22, lucene-solr#1601 -> #1617; a premature delete, the opposite imbalance); #9371 / LUCENE-8324 (a dropped flushed segment's files, fixed in 7.4); #10445 / LUCENE-9405 and lucene-solr#2288 / LUCENE-9722 (other resources of 100% deleted merges); #12228 (unreferenced files after a full-disk merge failure); open #14483 (merge-on-X stress tests run out of file handles); open #16625 / #16643 (rollback deletes too early); open PR #16571 (changes IndexWriter.java but not finishGetReaderMerge, mergedReaders or onGetReaderMergeResources); a downstream flaky disk-watermark test fixed by disabling merge on refresh, with no root cause. Gaps: GitHub search reads only the first page of results; list search used the archive's keyword API; web search was 6 queries.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr), dev@/java-user@/issues@/commits@ lists, JIRA, the web | 2026-10-26 | scripted prior-report search, 3 specs, 96 queries (finishGetReaderMerge, mergedReaders, onGetReaderMergeResources, segmentDropped, maxFullFlushMergeWaitMillis, merge on refresh, 100% deleted, unreferenced files ...); 12 direct GitHub searches; LUCENE and SOLR JIRA queries, 4 issues read in full | no report; related: #10005, lucene-solr#1601/#1617, #9371, #10445, lucene-solr#2288, #12228, #14483 |
| Introducing discussion and open PRs | 2026-10-26 | all 81 comments and reviews of lucene-solr#1623 and all 134 comments of #10005; the changed files of all 409 open apache/lucene PRs | no mention of a dropped merged segment's files; no open PR touches finishGetReaderMerge or mergedReaders |
| GitHub issues and PRs (apache/lucene), upstream main | 2026-10-31 | finishGetReaderMerge, mergedReaders, "merge-on-refresh leak", "merge on refresh unreferenced files", "getReader merge dropped segment"; IndexWriter.java on main; the diff of open PR #16571 | no report; main 2f424fd428da unchanged at IndexWriter.java:749-750; #16571 does not touch the leak site |
Introduced in 8.7.0 by LUCENE-8962 "Merge segments on getReader" (lucene-solr#1623, 8294e1ae206, 2020-08-24): opt-in until 9.2.0, on by default since 9.3.0 (LUCENE-10078, #921). Every release tested from 8.7.0 to 10.6.0 fails (before 9.3.0 with the feature turned on); upstream main is unchanged.
Versions tested8 rows
Introduced by LUCENE-8962 "Merge segments on getReader", apache/lucene-solr#1623 (s1monw, co-authored by mikemccand): 8294e1ae206 on master and its branch_8x backport 2f2146aad46, with an identical IndexWriter.java patch (same git patch-id for that file), both 2020-08-24. It added merge-on-refresh, the early incRef and the close-without-decRef together. Exposure changed with LUCENE-10078 (#921, 2022-06-07: b5795db0cf5 on main, released in 10.0.0, and its branch_9x backport 7d9de759f24, released in 9.3.0), which raised the default wait from 0 to 500 ms and gave TieredMergePolicy full-flush merges; it did not touch the leaking code. Later commits only reformatted or cleaned up the code. Not executed: 8.8.1 to 8.10.x and 9.4 to 9.11 (all release tags from 8.7.0 on contain the commit or its backport).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20; 10.6.0; 10.5.2 | affected | minimal test fails with assertions on and off: _2's 10 files left after close() |
| 9.12.3, 9.3.0 | affected | minimal test fails (assertions on) |
| 9.2.0 | opt-in only | minimal test passes (merge-on-refresh off by default); the hunt's opt-in test fails |
8.7.0, 8.8.0, 8.11.4, 9.0.0, 9.2.0, 9.3.0, 9.12.3, 10.0.0, 10.5.2, main ea57360912ed | opt-in test | the hunt's test (merge-on-refresh set explicitly) fails, controls pass |
| 8.7.0 with the fix | not affected | passes 4 of 4 |
| 8.6.3 | API absent | cannot compile the test: no MergeTrigger.GET_READER, no setMaxFullFlushMergeWaitMillis |
upstream main 2f424fd428da (2026-10-30) | read | close-and-clear unchanged at IndexWriter.java:749-750 |
645d4fe14a20 with either fix | not affected | repro passes; 1,396 core index tests pass |
Reproduced by the finder, independent reproducers, the adjudication, a credibility audit, a main refresh, the repro-minimization step and this doc. Cross-model reviews (OpenAI GPT-5.6, reading only) also called it real; they executed nothing.
Checks and verdicts308 words
Every reproduction ran on stock builds; the fix checks ran with the patched IndexWriter.
| Check | Verdict | What it found |
|---|---|---|
| Finder (TLA+ model, TLC; JUnit) | candidate | TLC traces (8 and 9 states); JUnit fails on main, 10.5.2, 9.12.3, 9.3.0, 8.11.4; hook-free loop leaks, 0 with the fix. |
| cross-model review (OpenAI GPT-5.6, reading only) | real, 0.98 | Root cause correct; found that fix v1 was not exception-safe, fixed in v2. |
| Independent reproducer | real | Own JUnit (delete by term plus flush; another thread's delete plus commit): 3 of 5 fail on main, controls pass; also 10.5.2 and 9.12.3; tight loop leaks hundreds of segments, 0 with a probe fix. |
| Cross-model adversary, works-as-designed lens (OpenAI GPT-5.6, reading only) | real, high | Intended argument rated weak; a separate run re-ran the finder's repro and loop: they fail as claimed. |
| Intended-behaviour devil's advocate | real, high | Built and rejected the works-as-designed case; filtered repro 40 of 40 fail, controls 10 of 10 pass. |
| Adjudication | confirmed, high | Re-ran the independent reproduction's JUnit with assertions on and off and the probe fix; impact minor. |
| Credibility audit | keep | Fresh seeds on main; fix v2 passes; hook-free loop leaked 9 segments, 0 with the fix. |
| Main refresh (a9779ef3c193) | still reproduces | The independent reproduction's JUnit: 3 of 5 fail, same files. |
| Repro minimization (2026-10-29) | verified | The one-test repro fails on 645d4fe14a20 with -ea and -da, 10 of 10 random seeds; fix v2 passes. |
| This doc (2026-10-31) | reproduced, 2 fixes validated | Gradle on 645d4fe14a20 with assertions on and off; releases 10.6.0, 10.5.2, 9.12.3, 9.3.0 fail and 9.2.0 passes; both fixes pass the core index tests. |
| Milder hook-free loop, re-run 2026-10-31 | no leak in 5 runs | Timing-dependent: the same loop leaked 1 to 13 segments per run on 2026-10-26; the tight loop leaked 675 on 2026-10-31. |
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 524 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,372 words
- Registry title
- Merge-on-refresh never releases the file references of a merged segment that is dropped because all its documents were deleted during the merge, so IndexWriter leaves that segment's files on disk until the next writer opens the index
- Area
- index / IndexWriter NRT merge-on-refresh (getReader point-in-time merges) and IndexFileDeleter refcounts
- Priority
- Low, rank 1001: Merge-on-refresh in the default config since 9.3 can leak a dropped merged segment's files with no fault needed, but they only take up disk space until the next writer opens and results stay correct.
- Impact class
- minor
- Impact note
- Applications on 9.3.0 or later with the default IndexWriterConfig (merge-on-refresh on with a 500 ms wait, TieredMergePolicy proposing refresh merges of segments below its 16 MB floor) that refresh NRT readers while their own commits or flushes apply deletes or updates leak one small merged segment each time every document of a refresh merge is deleted while it runs (8.7.0 to 9.2.0: only with merge-on-refresh opted in); the files stay on disk and in the directory listing for the rest of that writer's life, through deleteUnusedFiles(), commit(), forceMerge, close() and rollback(), and the next IndexWriter opened on the directory deletes them; nothing is deleted too early, searches and commits stay correct, CheckIndex is clean, no scale is needed (three documents), and real-workload leak rates were not measured.
- Summary
- You keep an IndexWriter open, refresh near-real-time readers from it (DirectoryReader.openIfChanged) with merge-on-refresh on, which is the default since 9.3, and while a refresh-triggered merge of small segments runs, a commit or flush applies deletes that remove every document of that merge. You expect the merged segment, which IndexWriter drops as 100% deleted, to have its files deleted, as happens when the same segment is dropped by an ordinary merge. Instead the refresh has already taken a file reference on it and later closes its reader without releasing that reference, so the segment's files stay in the directory through deleteUnusedFiles(), commit(), close() and rollback(), until the next IndexWriter opens the index.
- Introduced
- 8294e1ae2068 (master) and its branch_8x backport 2f2146aad46b (same day, identical IndexWriter.java patch, equal git patch-id): LUCENE-8962 'Merge segments on getReader' = apache/lucene-solr#1623 (s1monw, co-authored by mikemccand; JIRA now apache/lucene#10005). It added merge-on-refresh and both halves of the leak: the GET_READER callback, run from onMergeComplete at the start of commitMerge before the deletes made during the merge are carried over, opens the merged reader and calls deleter.incRef(sci.files()); finishGetReaderMerge closes the readers left in mergedReaders and clears the map without decRef [read: git show, git log -S / -L in the full-history clone; exec: 8.6.3 cannot compile the repro because the API does not exist, 8.7.0 fails, and 8.7.0 with the main fix shadowed into finishGetReaderMerge passes 4 of 4]. Exposure, not code, changed with LUCENE-10078 (apache/lucene#921, b5795db0cf5, 9.3.0), which turned merge-on-refresh on by default (2020-08-24 (8294e1ae206 merged as lucene-solr#1623; branch_8x backport 2f2146aad46 the same day))
- First affected version
- 8.7.0 [exec: fails; 8.6.3, the last release before, cannot compile the repro because MergeTrigger.GET_READER and IndexWriterConfig.setMaxFullFlushMergeWaitMillis do not exist]; opt-in from 8.7.0 to 9.2.0, default configuration from 9.3.0 (LUCENE-10078) [read]
- Affected versions
- every release from 8.7.0 through 10.6.0, and main ea57360912ed. [exec] 8.7.0, 8.8.0, 8.11.4, 9.0.0, 9.2.0, 9.3.0, 9.12.3, 10.0.0, 10.5.2 and main fail with -ea (also with -da on 8.7.0, 8.11.4, 9.12.3, 10.5.2 and main), and both controls pass in every run; a sibling probe that drops a merge-on-commit merged segment the same way is clean on 8.6.3, 8.7.0, 10.5.2 and main, so the leak is specific to the getReader path. [read] all 36 release tags from 8.7.0 on in the history clone contain the commit or its backport, and the leaking code changed afterwards only by reformatting and stream or comment cleanups; 8.8.1 to 8.10.x and 9.4 to 9.11 were not executed. Exposure: opt-in in 8.7.0 to 9.2.0 (maxFullFlushMergeWaitMillis > 0 plus a merge policy that returns full-flush merges), default configuration from 9.3.0. Not fixed upstream: upstream main IndexWriter.java still has the close-and-clear at :749-750 (read 2026-10-26); 10.6.0 (released 2026-10-29) is affected [exec 2026-10-31: the minimal test fails on the 10.6.0 release jars (SHA-1 checked against Maven Central) with -ea and -da]
- 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
- semi-formal: TLA+/TLC, code reading; also reached by stress
- Upstream and prior reports
- none found (2026-10-26, read-only): three scripted prior-report search specs (96 queries over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web), 12 direct GitHub searches (apache/lucene, Elasticsearch, OpenSearch, Solr), LUCENE and SOLR JIRA queries with 4 issues read in full, all 81 comments of the introducing PR lucene-solr#1623 and all 134 comments of #10005 (LUCENE-8962), and the changed files of all 409 open apache/lucene PRs (none touches finishGetReaderMerge or mergedReaders); plus the searches of the finder (two scripted prior-report searches), the independent reproduction, the intended-behaviour check and the adjudication. Nearest, not this bug: #10005 comment of 2020-06-22 and lucene-solr#1601 -> #1617 (the merge-on-commit dropped-segment reference problem, a premature delete, fixed before 8.6.0 by the segmentDropped check in mergeFinished); #9371 (LUCENE-8324, a dropped flushed segment's files kept until the next checkpoint, fixed in 7.4); #10445 (LUCENE-9405, merge readers closed twice for a 100% deleted merge); lucene-solr#2288 (LUCENE-9722, an aborted empty merge left its input readers open); #12228 (unreferenced files after a merge fails on a full disk); open #14483 (merge-on-commit and merge-on-getReader stress tests run out of file handles, an open-handle symptom attributed to test tuning); open #16625 / PR #16643 (rollback deletes files too early); open PR #16571 (rewrites commitMerge's drop path, keeps onMergeComplete first, leaves finishGetReaderMerge alone); OpenSearch #4162 / #4828 (a flaky disk-watermark test fixed by disabling merge on refresh, no root cause given; cannot be attributed to this bug). Not fixed upstream (upstream main IndexWriter.java unchanged at :749-750).
- Lucene commit tested
- main
645d4fe14a20(2026-10-31): the minimal test fails through gradle in a scratch worktree with assertions on and off; with either fix it passes, and all coreorg.apache.lucene.index.*tests (215 suites, 1,396 tests) pass with assertions on and off, as do core's format, ECJ and forbidden-apis checks (fix v2 after./gradlew tidy). Releases 10.6.0 and 10.5.2 fail the minimal test both ways, 9.12.3 and 9.3.0 with assertions on; 9.2.0 passes (merge-on-refresh off by default there). The hunt's opt-in test failed on 8.7.0, 8.8.0, 8.11.4, 9.0.0, 9.2.0, 9.3.0, 9.12.3, 10.0.0 and 10.5.2 and on mainea57360912ed, whoseIndexWriter.javamatches645d4fe14a20.
- Finding record
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:636-648 (GET_READER callback: mergedReaders.put and deleter.incRef(sci.files())), :649-667 (onGetReaderMergeResources: close plus decRef of leftovers; comment :651-655), :727-753 (finishGetReaderMerge; :749-750 IOUtils.close(mergedReaders.values()); mergedReaders.clear()), :755-790 (maybeReopenMergedNRTReader: decRefs only the readers it consumes; comment :769-771), :3969-3980 (mergeFinished applies the merge only when segmentDropped == false), :4031-4045 (onMergeComplete guard info.info.maxDoc() > 0), :4894-4896 (commitMerge calls onMergeComplete first), :4946-4958 (allDeleted, dropSegment), :5002-5007 (readerPool.drop, deleteNewFiles), :2567-2571 (rollback javadoc), :6171-6181 (deleteUnusedFiles javadoc)lucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:47-50 (refcount zero -> file deleted), :634-636 (deleteNewFiles uses deleteFilesIfNoRef)lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:108 (DEFAULT_MAX_FULL_FLUSH_MERGE_WAIT_MILLIS = 500)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:619-666, 684-724, 727-753, 755-793, 3940-4045, 4894-4958, 5002-5014a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:40-50, 455-486, 633-637a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/FileDeleter.java:154-156, 173-191
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:108, 482-498; index/MergePolicy.java:689-714; index/TieredMergePolicy.java:79, 187-189; index/LiveIndexWriterConfig.java:143
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:649-666, 787-789, 2566-2571, 4031-4045, 6170-6181a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:47-50a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestIndexWriter.java:216-234a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:847-848; lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:1770-1788
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:704-707, 716, 723, 741, 3940-3948a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:482-498; store/FSDirectory.java:97, 312, 337-366
- Diagrams
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:639-648, 727-753, 4894-4896, 4946-4958, 5002-5007
- TestGetReaderMergeDroppedSegmentLeak.java (one test class):
repro/LFV-472/src/org/apache/lucene/index/TestGetReaderMergeDroppedSegmentLeak.java(73 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and executed runs.
assets/LFV-472/bug.png(524 KB)
