All findings · Lucene finding · LFV-472 · getreader-merge-dropped-segment-refcount-leak

Merge-on-refresh never releases the files of a merged segment whose documents were all deleted during the merge

When every document of a refresh-time merge (merge-on-refresh, the default since 9.3) is deleted while it runs, IndexWriter drops the merged segment but never releases the file reference the refresh took on it. Its files stay on disk, even after close() or rollback(), until the next writer opens the index.

Summary

Priority
Low, rank 1001Merge-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
minor
Since
8.7.0 (LUCENE-8962, 2020); default config since 9.3.0introduced 2020-08-24 · about 6 years
Affected
8.7.0 to 10.6.0 and main
Status
documented
Found by
semi-formal: TLA+ model of file reference counting, TLC trace

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

The finding in 30 seconds

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.

How a refresh merge holds on to files

  1. 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.
  2. IndexWriter counts references per index file and deletes a file when its count drops to zero.
  3. 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.
  4. If every document of a merge was deleted while it ran, IndexWriter drops the merged segment instead of adding it, and deletes its files.
Step-by-step diagram of the minimal repro: segments _0 and _1, each with one document, are merged by a near-real-time refresh into _2, and the refresh takes a reference on _2's files. Both documents are deleted while the merge runs, so IndexWriter drops _2 as 100% deleted, but its file deletion skips the referenced files. The refresh closes the unused reader without releasing the reference, so _2's 10 files are still on disk after close(), outside every commit, until the next IndexWriter opens the directory. With the fix nothing is left.
One refresh, step by step: the refresh merges _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.

The finding, step by step

  1. Open an IndexWriter with 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, _0 and _1.
  2. Refresh with DirectoryReader.open(writer): it merges _0 and _1 into _2. While that merge runs, both documents are deleted (in the test by a delete plus flush() from a merge hook; in real use by another thread's update and commit).
  3. IndexWriter sees that _2 is 100% deleted and drops it. You expect its files to be deleted, as they are when forceMerge drops the same segment.
  4. 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. After w.close() all 10 files of _2 are still on disk and no commit references them; only the next IndexWriter deletes them.
  5. 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.

Who is affected

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.

The code (IndexWriter.java at 645d4fe14a20, abridged)

// 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;

Step by step (the minimal test)

  1. DirectoryReader.open(w) runs getReader. With maxFullFlushMergeWaitMillis > 0 (500 by default) it prepares point-in-time merges for MergeTrigger.GET_READER (:619-648), then finishGetReaderMerge runs the merge and waits for it (:738-739).
  2. The merge writes _2. commitMerge starts with merge.onMergeComplete() (:4894-4896). The wrapper's onMergeComplete (:4031-4045) has three conditions (:4034-4037); its only check against a dropped segment is info.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. _2 has maxDoc 2, so the callback runs: reader opened, put in mergedReaders, incRef of _2's files.
  3. commitMerge carries over the deletes made during the merge (:4936-4937), finds the segment allDeleted (:4946-4949) and drops it with readerPool.drop and deleteNewFiles (:5002-5007). deleteNewFiles deletes only files without a reference (IndexFileDeleter.java:633-636, FileDeleter.java:154-156, 173-191), so _2's files survive.
  4. The wrapper's mergeFinished applies a merge to the point-in-time SegmentInfos only when segmentDropped == false (:3978-3980). maybeReopenMergedNRTReader removes a reader from mergedReaders, and later decRefs its files, only when its segment is in those SegmentInfos (:772, :780, :787-789). _2 is not, so its reader stays in the map.
  5. finishGetReaderMerge closes it and clears the map (:749-750). onGetReaderMergeResources.close() at the end of getReader (:723) finds nothing to release.
  6. _2's files keep a reference count of 1 for the rest of the writer's life. IndexFileDeleter.refresh(), which rollback() uses, also skips referenced files (IndexFileDeleter.java:455-486, :467). The next IndexWriter starts with fresh counts and deletes them as unreferenced.

Contrast

what happens in the same writerthe 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 maybeMergedeleted (no callback, no reference)
the same drop through merge-on-commitdeleted: it takes its reference in mergeFinished, only when the merge is applied (:3978-3995)
the same refresh merge without deletesthe 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 IndexWriterConfig that 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 with setMaxFullFlushMergeWaitMillis > 0 and a merge policy that returns full-flush merges.
  • Settings. The default TieredMergePolicy proposes 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() and rollback(); the next IndexWriter open 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 after close().
  • 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 the IndexWriter from time to time. 9.2.0, where the wait is 0 by default, passes the minimal test.

Minimal repro

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

TestGetReaderMergeDroppedSegmentLeak.java (one test class)73 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.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);
    }
  }
}
Command
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.

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

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.

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run)
Command (environment)
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

Observed output

# 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

Why it looks unintended

  • The code's own cleanup, onGetReaderMergeResources, pairs each close of a leftover reader with a decRef; finishGetReaderMerge empties 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 forceMerge or 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 does decRef then close for each (IndexWriter.java:651-664); maybeReopenMergedNRTReader decRefs the readers it consumes (:787-789). Only finishGetReaderMerge closes without decRef, 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 same commitMerge.
  • 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.assertNoUnreferencedFiles fails 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.

Suggested fix

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.

Leave the leftover readers to the existing cleanup (validated by the checks) (recommended)

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

  • mergedReaders is local to the getReader call, and nothing adds to it once stopCollectingMergedReaders is set (:741). onGetReaderMergeResources.close() runs at the end of getReader (:723) or in the error path (:716) and does decRef plus 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 (with SerialMergeScheduler that 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 core org.apache.lucene.index.* tests (215 suites, 1,396 tests, 56 skipped, 0 failures) pass with assertions on and off; core checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain pass.
  • The 4 s default-config loop leaks 0 files (stock: 2,025).

Release the leftovers in finishGetReaderMerge (the hunt's fix v2) (validated by the checks)

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, TestIndexWriterReader and TestIndexFileDeleter (57 tests) and all core org.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 the Stream.of call 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.

Take the reference only when the merge is applied (not suitable)

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

Polish for either fix: close each reader before its decRef

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.

Workaround for users until a fix ships (not a fix)

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.

Downstream note

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.

How it was found

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 IndexFileDeleter reference-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 incRef has no decRef on 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, commitMerge drops 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr), dev@/java-user@/issues@/commits@ lists, JIRA, the web2026-10-26scripted 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 fullno report; related: #10005, lucene-solr#1601/#1617, #9371, #10445, lucene-solr#2288, #12228, #14483
Introducing discussion and open PRs2026-10-26all 81 comments and reviews of lucene-solr#1623 and all 134 comments of #10005; the changed files of all 409 open apache/lucene PRsno mention of a dropped merged segment's files; no open PR touches finishGetReaderMerge or mergedReaders
GitHub issues and PRs (apache/lucene), upstream main2026-10-31finishGetReaderMerge, mergedReaders, "merge-on-refresh leak", "merge on refresh unreferenced files", "getReader merge dropped segment"; IndexWriter.java on main; the diff of open PR #16571no report; main 2f424fd428da unchanged at IndexWriter.java:749-750; #16571 does not touch the leak site

History

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

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; 10.6.0; 10.5.2 affectedminimal test fails with assertions on and off: _2's 10 files left after close()
9.12.3, 9.3.0 affectedminimal test fails (assertions on)
9.2.0 opt-in onlyminimal 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 testthe hunt's test (merge-on-refresh set explicitly) fails, controls pass
8.7.0 with the fix not affectedpasses 4 of 4
8.6.3 API absentcannot compile the test: no MergeTrigger.GET_READER, no setMaxFullFlushMergeWaitMillis
upstream main 2f424fd428da (2026-10-30) readclose-and-clear unchanged at IndexWriter.java:749-750
645d4fe14a20 with either fix not affectedrepro passes; 1,396 core index tests pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (TLA+ model, TLC; JUnit) candidateTLC 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.98Root cause correct; found that fix v1 was not exception-safe, fixed in v2.
Independent reproducer realOwn 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, highIntended argument rated weak; a separate run re-ran the finder's repro and loop: they fail as claimed.
Intended-behaviour devil's advocate real, highBuilt and rejected the works-as-designed case; filtered repro 40 of 40 fail, controls 10 of 10 pass.
Adjudication confirmed, highRe-ran the independent reproduction's JUnit with assertions on and off and the probe fix; impact minor.
Credibility audit keepFresh seeds on main; fix v2 passes; hook-free loop leaked 9 segments, 0 with the fix.
Main refresh (a9779ef3c193) still reproducesThe independent reproduction's JUnit: 3 of 5 fail, same files.
Repro minimization (2026-10-29) verifiedThe 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 validatedGradle 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 runsTiming-dependent: the same loop leaked 1 to 13 segments per run on 2026-10-26; the tight loop leaked 675 on 2026-10-31.

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

Finding record

LFV-472, snapshot of 2026-11-06

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 core org.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 main ea57360912ed, whose IndexWriter.java matches 645d4fe14a20.

Lucene and upstream sources

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-5014
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:40-50, 455-486, 633-637
  • a 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-6181
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:47-50
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestIndexWriter.java:216-234
  • a 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-3948
  • a 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

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and 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.