All findings · Lucene finding · LFV-502 · fullflush-merge-rejected-by-registermerge-still-awaited

IndexWriter waits for and leaks a full-flush merge it refused to register, instead of throwing the documented IllegalStateException

If a merge policy's findFullFlushMerges proposes a segment that another merge already holds, its javadoc says commit() throws IllegalStateException. Instead IndexWriter quietly refuses the merge but still opens and waits for it: each such commit stalls the full 500 ms, and the files stay open after close().

Summary

Priority
Low, rank 1007Only a custom findFullFlushMerges that proposes already-merging segments triggers a bounded commit stall and leaked readers.
Impact
minor
Since
8.6.0 (LUCENE-8962, #1617, 2020); the getReader path since 8.7.0introduced 2020-06-27 · about 6 years
Affected
every release run from 8.6.0 to 10.6.0, and main
Status
documented
Found by
non-formal: code reading while modelling merge on commit in TLA+

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

A merge that IndexWriter refuses to start is still opened and waited for.

Background: merge on commit

  1. On commit(), prepareCommit() or DirectoryReader.open(writer), IndexWriter asks the merge policy's findFullFlushMerges for merges of small, just-flushed segments, starts them and waits up to maxFullFlushMergeWaitMillis (500 ms by default since 9.3.0).
  2. A segment can be in only one merge at a time. registerMerge refuses a merge if one of its segments is already merging; a refused natural merge simply does not happen.
  3. The findFullFlushMerges javadoc says what happens if a policy proposes such a merge anyway: commit() "will throw a IllegalStateException". Policies are told to consult MergeContext#getMergingSegments().
Diagram of the bug: during commit, the merge policy's findFullFlushMerges proposes two merges that share segment _1, {_0, _1} and {_1, _2}. registerMerge accepts {_0, _1} and refuses {_1, _2} because _1 is already marked for merge, but IndexWriter keeps the refused merge in the list it opens readers for and waits on. The accepted merge finishes in 25 ms; the refused one never runs, so the commit waits the full 500 ms timeout and returns without the documented IllegalStateException, and the readers of _1 and _2 stay open after IndexWriter.close(). With the fix, commit throws IllegalStateException before any reader is opened.
One commit, three one-document segments: the policy proposes {_0, _1} and {_1, _2}. IndexWriter registers the first and refuses the second, yet opens readers for both and waits for both. The first merge ends in 25 ms; the commit waits out the 500 ms timeout, throws nothing, and _1 and _2 stay open after close(). Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Use a merge policy whose findFullFlushMerges returns two merges that share a segment, {_0, _1} and {_1, _2}, or proposes a segment a background merge already holds. setCommitOnClose(false), everything else default: merge on commit waits up to 500 ms.
  2. Index three one-document segments and call commit(). You expect IllegalStateException: the javadoc says commit "will throw" when a proposed merge includes a segment already in a registered merge.
  3. Instead IndexWriter refuses {_1, _2} with only an info-stream line ("already marked for merge") but keeps it in the list it opens and waits for. {_0, _1} merges in 25 ms; the commit waits out the 500 ms timeout and returns normally after 528 ms.
  4. The refused merge never runs, so its readers are never closed: after IndexWriter.close(), _1.cfs and _2.cfs are still open. Overlapping merges like these hit every commit; a policy that only ignores getMergingSegments() is hit when a natural merge holds one of its segments (40 of 40 commits stalled and 199 files left open in a probe tuned for frequent natural merges; 0 of 200 commits with default settings).

Who is affected

Applications whose own findFullFlushMerges returns overlapping merges (hit on every commit) or ignores MergeContext#getMergingSegments() (hit when a natural merge holds one of its segments at commit time). Lucene's policies skip merging segments; a read-only check of Elasticsearch, OpenSearch and Solr found none affected. Each affected commit stalls 500 ms and open files pile up.

Mechanism, terms and numbers788 words

With merge on commit or merge on refresh (on by default since 9.3.0), IndexWriter asks the merge policy's findFullFlushMerges for merges and waits up to maxFullFlushMergeWaitMillis (500 ms) for them. When a proposed merge includes a segment that is already in a registered merge, the javadoc says commit() and prepareCommit() throw IllegalStateException. They do not: updatePendingMerges ignores registerMerge's refusal, so the refused merge stays in the spec that preparePointInTimeMerge opens readers for and that commit and getReader wait on. A refused merge never runs, so each such commit or NRT reopen waits the whole timeout, the merge's readers and file references outlive IndexWriter.close() (199 files open after 40 commits with a naive policy, in a probe tuned for frequent natural merges), and with a long wait DirectoryReader.open(writer) keeps waiting after another thread's rollback(). Only a custom policy that returns overlapping merges (hit on every commit) or ignores MergeContext#getMergingSegments() (hit when a natural merge holds one of its segments) triggers it; Lucene's own policies do not. It has been in every release since 8.6.0.

The mechanism (main 645d4fe14a20)

  1. Refusal ignored. IndexWriter.updatePendingMerges gets the spec from findFullFlushMerges for COMMIT and GET_READER (IndexWriter.java:2494-2497), calls registerMerge(spec.merges.get(i)) for every merge without looking at the boolean result, and returns the whole spec (:2511-2517).
  2. What a refusal is. registerMerge returns false when one of the merge's segments is already in mergingSegments ("reject merge ... is already marked for merge", :5171-5182) or is not in the live infos (:5184-5194). Only an accepted merge goes into pendingMerges (:5206) and gets registerDone = true (:5247). For natural merges this is harmless: nobody uses the returned spec.
  3. Readers opened anyway. preparePointInTimeMerge (:3950-4110) runs initMergeReaders on every merge of the returned spec, through getPooledInstance(sci, true) and getReaderForMerge(..., mr -> deleter.incRef(...)) (:4078-4089). A refused merge never runs and is never aborted, so these readers and IndexFileDeleter references are never released.
  4. Awaited anyway. Commit: prepareCommitInternal calls preparePointInTimeMerge(..., COMMIT, ...) when the wait is above 0 (:3858-3865), then mergeScheduler.merge(mergeSource, COMMIT) and pointInTimeMerges.await(maxCommitMergeWaitMillis, ...) (:3886-3906), inside synchronized (commitLock) (:3775). getReader: preparePointInTimeMerge(..., GET_READER, ...) (:634-648) and the same await (:738-739). MergeSpecification.await waits for every merge's completion future (MergePolicy.java:546-557); the refused merge's future never completes, so the wait always runs to its timeout. That wait is bounded and interruptible. (With SerialMergeScheduler, mergeScheduler.merge first runs the accepted merges in the calling thread without the timeout, IndexWriterConfig.java:495-497; the extra wait for the refused merge is still timed.)
  5. Rollback cannot help. abortMerges completes only pending and running merges (:2821-2844), so rollback() cannot release a waiting getReader. rollback() first claims the close (shouldClose, :1374-1394) and then takes commitLock (:2589): a waiting getReader holds no lock it needs, but a stalled commit does, so rollback waits behind it.
  6. The authors assumed it cannot happen. The error path of preparePointInTimeMerge, which runs only if opening the merge readers fails, asserts "merge should be pending but isn't" for every merge of the spec (:4099).

The numbers

The repro's input with the info stream on: "add merge to pendingMerges: _0 _1", "reject merge _1 _2: segment _1 is already marked for merge", "now run merges during commit" listing both merges. {_0, _1} merges into _3 in 25 ms, but "done waiting for merges during commit" comes 504 ms after the wait starts and "commit: took 527.9 msec". The writer then deletes _0's files but not _1's, which the refused merge still references. With MockDirectoryWrapper, close() leaves _1.cfs and _2.cfs open. A natural merge already running when the commit proposes an overlapping merge gives the same: no exception after a 2,045 ms wait (2,000 ms configured). A naive "merge all segments under 1 MB on commit" policy that ignores getMergingSegments(), over a TieredMergePolicy with setSegmentsPerTier(2.0), with setMaxBufferedDocs(2), the default scheduler and wait, 400 documents and a commit every 10, took at least 480 ms (the probe's threshold for about the full wait) on 40 of 40 commits (max 533 ms) and left 199 files open after close(); the same policy skipping getMergingSegments(): 0 of 40 slow commits (max 18 ms), clean close. The two non-default settings keep natural merges of the small segments running almost all the time: with both at their defaults the same probe gave 0 of 200 slow commits over 5 runs and a clean close every time. So a policy that only ignores getMergingSegments() is hit only when a natural merge holds one of its segments at commit time; one that returns overlapping merges is hit on every commit.

Exposure and likelihood394 words
  • Who. Only a custom findFullFlushMerges that proposes a segment already in a registered merge, or two overlapping merges in one spec. TieredMergePolicy and LogMergePolicy skip merging segments in findMerges (TieredMergePolicy.java:347-349, LogMergePolicy.java:604, :638), and the default findFullFlushMerges is a size-filtered subset of findMerges (MergePolicy.java:689-714). MockRandomMergePolicy and the sandbox MergeOnFlushMergePolicy filter too. A read-only check of the default branches of Elasticsearch, OpenSearch and Solr (2026-10-26, no revisions pinned) found no affected policy: OpenSearch's CriteriaBasedMergePolicy drops merging segments before it delegates, Solr's LatestVersionMergePolicy delegates on filtered infos, and in Elasticsearch only one test overrides findFullFlushMerges.
  • Default vs opt-in. The policy is application code. A policy that returns overlapping merges needs nothing else since 9.3.0 (merge on full flush on by default, 500 ms). A policy that only ignores getMergingSegments() also needs a natural merge running over one of its segments at commit time. In 8.6.0-9.2.0 the path ran only with an explicit non-zero wait.
  • How often. On every commit or NRT reopen for a policy that returns overlapping merges. For a policy that only ignores getMergingSegments(), whenever a natural merge holds one of the proposed segments: 40 of 40 commits in a probe tuned for frequent natural merges (setSegmentsPerTier(2.0), setMaxBufferedDocs(2)), 0 of 200 with default settings.
  • Effect. Each affected commit or reopen takes the full timeout instead of milliseconds, holding commitLock meanwhile. Two related leaks accumulate with ongoing indexing (199 files open after 40 commits in the tuned probe): the refused merge's readers are never closed, so their file handles stay open even after close(); and its IndexFileDeleter references keep this writer from deleting the merged-away files (once the writer is closed, a later IndexWriter on the directory can delete them). With a long configured wait, DirectoryReader.open(writer) keeps waiting after another thread's rollback() (still waiting 20 s later). No wrong results or data loss: the merges that registered are applied. CheckIndex was not run after the leak.
  • Likelihood: rare. It takes a policy that ignores documented guidance, and the public searches found no prior report.
  • Workarounds. Skip MergeContext#getMergingSegments() in findFullFlushMerges and never return overlapping merges: the probe's control shows that is clean. Or set setMaxFullFlushMergeWaitMillis(0), which turns merge on commit and on getReader off altogether (IndexWriterConfig.java:493; commit and getReader run preparePointInTimeMerge only when the wait is above 0, IndexWriter.java:3858, :619).

Minimal repro

One JUnit test on public API: a FilterMergePolicy whose findFullFlushMerges returns {_0, _1} and {_1, _2}, three one-document segments, an IndexWriterConfig with setCommitOnClose(false) and defaults otherwise, and a ByteBuffersDirectory. It asserts the documented IllegalStateException from commit(). Deterministic (the accepted merge runs on the default scheduler's merge thread; the refused one never runs), about 2 s.

TestRejectedFullFlushMerge.java (one test)51 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.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestRejectedFullFlushMerge extends LuceneTestCase {

  public void testCommitThrowsOnOverlappingFullFlushMerges() throws Exception {
    MergePolicy overlapping =
        new FilterMergePolicy(NoMergePolicy.INSTANCE) {
          @Override
          public MergeSpecification findFullFlushMerges(
              MergeTrigger trigger, SegmentInfos infos, MergeContext context) {
            MergeSpecification spec = new MergeSpecification();
            spec.add(new OneMerge(List.of(infos.info(0), infos.info(1))));
            spec.add(new OneMerge(List.of(infos.info(1), infos.info(2))));
            return spec;
          }
        };
    IndexWriterConfig iwc =
        new IndexWriterConfig().setMergePolicy(overlapping).setCommitOnClose(false);
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      for (int i = 0; i < 3; i++) {
        w.addDocument(new Document());
        w.flush();
      }
      // expected (MergePolicy#findFullFlushMerges javadoc): IllegalStateException; actual: none
      expectThrows(IllegalStateException.class, w::commit);
    }
  }
}
Command
cp $REPRO/TestRejectedFullFlushMerge.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestRejectedFullFlushMerge -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false
Expected
commit() throws IllegalStateException, as the MergePolicy#findFullFlushMerges javadoc says.
Observed
No exception: commit() returns normally after waiting about 500 ms for the refused merge ("Expected exception IllegalStateException but no exception was thrown").

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

More files, environment, full output268 words

The test sits in org.apache.lucene.index for locality only; every API it uses is public. ByteBuffersDirectory keeps the separate file leak from showing up as a suppressed failure at directory close, and setCommitOnClose(false) keeps close() from running the policy a second time (with the default, close() commits again and this policy, which assumes three segments, throws IndexOutOfBoundsException on the two that are left). It asserts only the documented exception; the stall (528 ms) is visible in the info stream, and the files left open after close() and the getReader wait after rollback() are shown by the other checks, not by extra tests.

Regression test: this same test, as its own class or as a method of TestIndexWriterMergePolicy. It passes core's checkGoogleJavaFormat, ecjLintTest and forbiddenApisTest as is.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT) and 10.6.0: fail; with the recommended fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run)

Observed output

TestRejectedFullFlushMerge > testCommitThrowsOnOverlappingFullFlushMerges FAILED
    junit.framework.AssertionFailedError: Expected exception IllegalStateException but no exception was thrown
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:342F1B8CD1AEAE62]:0)
        at app//org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1432)
        at app//org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1418)
        at app//org.apache.lucene.index.TestRejectedFullFlushMerge.testCommitThrowsOnOverlappingFullFlushMerges(TestRejectedFullFlushMerge.java:48)
1 test completed, 1 failed

# info stream of the same test (abridged)
IW: add merge to pendingMerges: _0(11.0.0):c1 _1(11.0.0):c1 [total 1 pending]
IW: reject merge _1(11.0.0):c1 _2(11.0.0):c1: segment _1(11.0.0):c1 is already marked for merge
IW: now run merges during commit: MergeSpec:
  1: _0(11.0.0):c1 _1(11.0.0):c1
  2: _1(11.0.0):c1 _2(11.0.0):c1
IW: now apply merge during commit: _0(11.0.0):c1 _1(11.0.0):c1 into _3
IFD: now delete 3 files: [_0.cfe, _0.si, _0.cfs]
IW: done waiting for merges during commit
IW: commit: took 527.9 msec

# with the recommended fix: BUILD SUCCESSFUL (1 test, 0 failures), assertions on and off

Why it looks unintended

  • The findFullFlushMerges javadoc names this input (a segment already in a registered merge, here the spec's first) and its outcome: commit() "will throw a IllegalStateException".
  • The first implementation threw it, by the reviewers' choice ("Less trappy"); the shipped rewrite dropped the check without discussion.
  • Beyond the exception: a merge that never runs should not hold files open past close().
The full argument303 words
  • The contract. MergePolicy.java:677-681: "If a OneMerge in the returned MergeSpecification includes a segment already included in a registered merge, then IndexWriter#commit() or IndexWriter#prepareCommit() will throw a IllegalStateException. Use MergeContext#getMergingSegments() to determine which segments are currently registered to merge." In the minimal test the registered merge is the spec's own first merge, registered a moment earlier: IndexWriter registers the proposed merges in order (IndexWriter.java:2513-2514) and rejects the second through the same "already marked for merge" branch (:5171-5182) as any other conflict. #1155's check threw in exactly this case. For the narrower reading (a merge registered before the policy ran), the finder's running-merge test shows the same missing exception: no exception after a 2,045 ms wait.
  • The design record. The first merge-on-commit implementation, lucene-solr #1155 (043c5dff6f4, 2020, reverted before any release), threw IllegalStateException("MergePolicy ... returned merging segments from findFullFlushMerges") when registerMerge returned false, and added that sentence. In review msfroh wrote "I suppose we could silently disregard the merge if we remember to count down the latch for the merge that fails to register. ... I lean slightly toward explosive failure", and mikemccand replied "OK I agree to explosive failure! Less trappy." The reverted #1552 was the first version without the check; the shipped #1617 re-landed the code without it. None of their comments or reviews discusses the change. Today's code does neither option: it neither throws nor disregards the merge.
  • Other text. IndexWriterConfig.java:482-488: full-flush merges that time out "are not aborted, and will still run to completion ... like natural segment merges"; a refused merge never runs. IndexWriter.java:4099 asserts "merge should be pending but isn't" for every point-in-time merge. Lucene's tests check through MockDirectoryWrapper that closing a writer releases every file.
  • getReader. No exception is promised there; that symptom rests on the leak and the waiter.
The devil's-advocate check105 words

The strongest "not a bug" case is misuse: the javadoc tells policies to use MergeContext#getMergingSegments(), every shipped policy does, MockRandomMergePolicy says "Do not return any merges involving already-merging segments", the sandbox TestMergeOnFlushMergePolicy asserts "findFullFlushMerges must not return already merging segments", and MergePolicy is expert API.

Why it fails: the same javadoc defines the result of exactly this input, so the input is inside the contract, not undefined behaviour. And "it fails silently instead of loudly" would excuse at most the missing exception, not readers and files that outlive close() or a waiter that rollback() cannot release. Misuse argues for low priority, not for intent.

Suggested fix

Recommended Throw the documented exception on commit, drop refused merges on getReader (a prototype, validated on the listed tests)

In IndexWriter.preparePointInTimeMerge, check which proposed merges registerMerge refused before opening any readers: for a commit, abandon the registered ones and throw the documented IllegalStateException; for an NRT reopen, drop the refused ones. Also release the commit's file references when this throws. The repro and all 1,396 core index tests pass with it.

if (pointInTimeMerges != null
    && pointInTimeMerges.merges.stream().anyMatch(merge -> merge.registerDone == false)) {
  if (trigger == MergeTrigger.COMMIT) {
    for (MergePolicy.OneMerge merge : pointInTimeMerges.merges) {
      if (merge.registerDone) { // abandon the registered ones, as the error path below does
        pendingMerges.remove(merge);
        try { abortOneMerge(merge); } finally { mergeFinish(merge); }
      }
    }
    throw new IllegalStateException("MergePolicy " + config.getMergePolicy().getClass()
        + " returned merging segments from findFullFlushMerges");
  }
  pointInTimeMerges.merges.removeIf(merge -> merge.registerDone == false); // GET_READER
}
All options and trade-offs4 options ยท 782 words

Two directions; maintainers may prefer either, and both change IndexWriter only. The recommended one was built and run through gradle in a scratch worktree of 645d4fe14a20; the alternative was checked with the patched class on the stock jars.

Throw the documented exception on commit, drop refused merges on getReader (a prototype, validated on the listed tests) (recommended)

IndexWriter.java +36/-3. Right after updatePendingMerges(...) returns in preparePointInTimeMerge, before any merge reader is opened; plus a cleanup in prepareCommitInternal.

if (pointInTimeMerges != null
    && pointInTimeMerges.merges.stream().anyMatch(merge -> merge.registerDone == false)) {
  if (trigger == MergeTrigger.COMMIT) {
    for (MergePolicy.OneMerge merge : pointInTimeMerges.merges) {
      if (merge.registerDone) { // abandon the registered ones, as the error path below does
        pendingMerges.remove(merge);
        try { abortOneMerge(merge); } finally { mergeFinish(merge); }
      }
    }
    throw new IllegalStateException("MergePolicy " + config.getMergePolicy().getClass()
        + " returned merging segments from findFullFlushMerges");
  }
  pointInTimeMerges.merges.removeIf(merge -> merge.registerDone == false); // GET_READER
}

Trade-offs

  • Matches the javadoc and the original #1155 behaviour and message. For getReader no exception is documented, so refused merges are dropped there.
  • Needs the cleanup in prepareCommitInternal: wrap the preparePointInTimeMerge(..., COMMIT, ...) call and deleter.decRef(toCommit.files(false)) before rethrowing, because the commit's files were incRef'd just before (IndexWriter.java:3857). Without it, rollback() after the exception leaves 9 files (_0, _1, _2 .cfe/.cfs/.si); with it, none. That is the leak path of LFV-456 (a separate, known bug with more throw sites); if a general fix for it lands first, the cleanup is unnecessary.
  • Exception safety (cross-model fact check, by reading): the loop that abandons the registered merges is a plain loop, while the existing error path uses IOUtils.applyToAll (IndexWriter.java:4093-4104), which goes on past an exception. Closing a merge runs the overridable OneMerge.mergeFinished; if that throws, the plain loop stops and later registered merges stay pending. A final patch should use the same applyToAll cleanup, and a test with a throwing mergeFinished would cover it. Not run.
  • Behaviour change: an application running such a policy today gets an exception on each affected commit instead of a slow commit and a leak. That is what the javadoc promises and what the reviewers chose ("Less trappy"), but it is visible. After the exception (by reading): it is not tragic, so the writer stays usable once the policy is corrected; with the same policy every later commit throws again; and with the default commitOnClose(true), close() commits, throws again, rolls the writer back (IndexWriter.java:1328-1335) and rethrows.

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle --no-daemon --offline, OpenJDK 25.0.3, seed DEADBEEF. Stock: the repro fails with assertions on and off.
  • With the fix: the repro passes, assertions on and off; TestIndexWriter*, TestConcurrentMergeScheduler, TestTieredMergePolicy, TestLogMergePolicy and the repro: 25 suites, 393 tests, 0 failures, assertions on and off; all core org.apache.lucene.index.* tests: 215 suites, 1,396 tests, 0 failures (assertions on); sandbox TestMergeOnFlushMergePolicy: 6 tests, 0 failures; core's checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain, forbiddenApisTest pass. Other modules and the full core suite were not run.
  • With the fixed class on the stock jars, the finder's 4 tests pass: both exception cases, no files open after close(), getReader returns after rollback(); the rollback probe leaves no files.

Drop refused merges for both triggers and change the javadoc (validated by the checks)

pointInTimeMerges.merges.removeIf(merge -> merge.registerDone == false) before the initMergeReaders loop, +6 lines; the findFullFlushMerges javadoc sentence then has to say the merge is skipped.

Trade-offs

Compatible and quiet: no stall, no leak, no new exception. But a policy bug stays silent (the merge just does not happen), the javadoc must change, and the repro here, which asserts the documented exception, fails with it by design; the regression test would have to assert the missing stall or leak instead.

What the checks validated

With the patched class on the stock jars (not through gradle): the naive-policy probe gives 0 of 40 slow commits (max 24 ms) and a clean close(); the finder's leak and getReader-rollback tests pass.

Variant: return only registered merges from updatePendingMerges

The same behaviour as the previous option, at the call site that ignores the result.

Trade-offs

Does not reach the sibling LFV-207, whose forceMergeDeletes has its own registration loop. Not run.

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

Make findFullFlushMerges skip MergeContext#getMergingSegments() and never return overlapping merges. Or set setMaxFullFlushMergeWaitMillis(0), which disables merge on commit and on getReader (IndexWriterConfig.java:493).

Trade-offs

The probe's control does the first: 0 of 40 slow commits and a clean close(). The second gives up merge on commit and on getReader altogether (by reading: commit and getReader call preparePointInTimeMerge only when the wait is above 0).

Downstream note

Downstream products are affected only if they ship their own findFullFlushMerges that proposes already-merging segments; a read-only check of the Elasticsearch, OpenSearch and Solr policies found none that does.

How it was found

Found by reading IndexWriter while writing the code map for a TLA+ model of merge on commit and merge on refresh; the model assumes every merge registers, so it played no part. A deterministic JUnit on the stock jars confirmed it, then an independent reproducer, an intended-behaviour devil's advocate with a naive-policy probe, reviews by OpenAI GPT-5.6 and an adjudication.

The method in full165 words

Method: non-formal (code reading while modelling, then dynamic checks).

  • Signal. The hunt unit modelled IndexWriter's point-in-time merges (merge on commit and merge on refresh) in TLA+. Writing the model's file:line table, the finder saw that updatePendingMerges ignores registerMerge's result while preparePointInTimeMerge opens and awaits the whole spec. The model's merge action registers every proposed merge, so no model output was involved.
  • Confirmation. A same-package JUnit on public and protected API (4 tests: two overlapping merges in one spec; a conflict with a running natural merge; files open after close(); getReader after rollback()) failed 4 of 4 on main with and without assertions and on 10.5.2, 9.12.3, 9.8.0, 9.0.0 and 8.11.4.
  • Then a review and an adversarial review by a model from another family (OpenAI GPT-5.6, reading only), an independent reproducer with its own JUnit, an intended-behaviour devil's advocate with a hook-free naive-policy probe, an adjudication, a history and prior-report follow-up, registry re-runs, a credibility audit, a repro-minimization step and this doc followed.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26, re-checked 2026-10-31): GitHub issues, PRs and commits, the JIRA archive, four mailing lists, downstream trackers, the titles and bodies of all 16,902 apache/lucene issues and PRs, eleven merge-on-commit PR threads and all 408 open PRs. The closest items are the feature's own design history.

Searches2 searches

Verdict new. Nothing reports the missing exception, the refused merge being opened and awaited, the leak past close(), or the getReader wait after rollback(). Design history, not reports: lucene-solr #1155 (threw the exception; reviewers chose it), #1552 and #1617 (dropped the check without discussion), #1623 (getReader). Related, different: #11126 (LUCENE-10088) and #14483 ("too many open files" in the merge-on-commit stress tests, traced to blocked flushes; their test policy filters merging segments); #14423 (.cfs left open by a test that skips close()); #10445 (LUCENE-9405, another merge-reader bug, fixed in 2020). Open PRs #12237, #14837 and #16571 touch nearby code but would not fix it. Limits: GitHub search is ranked and reads one page; the full scan covers titles and bodies, not comments (about 60 threads were read in full); private reports to the security list are not visible.

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, Apache JIRA, the web2026-10-26scripted prior-report search (85 queries), 72 GitHub GraphQL searches incl. the info-stream strings "reject merge" and "already marked for merge", a scan of all 16,902 issue and PR titles and bodies, 11 JIRA searches, eleven lucene-solr PR threads read in full, the changed files of all 408 open PRsno report; design history #1155, #1552, #1617, #1623; related #11126, #14483, #14423, #10445
GitHub issues, PRs and commits (apache/lucene)2026-10-31gh search for findFullFlushMerges, registerMerge, preparePointInTimeMerge, maxFullFlushMergeWaitMillis; commit history of IndexWriter.java on mainnothing new; last IndexWriter.java commit 2026-09-21

History

Arrived with merge on commit itself: LUCENE-8962 (#1617, 8.6.0, 2020) shipped without the check its reverted first version (#1155) had; #1623 (8.7.0) added the getReader path, and 9.3.0 made it reachable without settings. Fails on every release run from 8.6.0 to 10.6.0 and on main. Not fixed upstream.

Versions tested6 rows

Introduced by 7f352a96659 (main) and its branch_8x backport 9b1c92809e7, lucene-solr #1617 (LUCENE-8962 "Merge small segments on commit", 2020-06-27), first released in 8.6.0: it took the spec from updatePendingMerges (which #1585, 59efe22ac29, had just made return it), opened readers for and awaited every merge in it, and kept the javadoc sentence. The reverted #1155 (043c5dff6f4) had thrown the exception; the reverted #1552 (972c84022ec) was the first version without it. 8294e1ae206 / 2f2146aad46 (#1623, 8.7.0) carried the same wait to getReader. LUCENE-10078 (#921, 9.3.0) turned merge on full flush on by default with a 500 ms wait; before that the path ran only with an explicit wait. #13017 (9.11.0 / 10.0.0) added the IndexFileDeleter references a refused merge never releases.

Upstream main 2f424fd428da (2026-10-30) has the same IndexWriter.java, MergePolicy.java and IndexWriterConfig.java as 645d4fe14a20; branch_10_6 was not executed.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; 10.6.0 affectedminimal test: no exception, assertions on and off
main 645d4fe14a20 other symptoms2 files open after close(); getReader still waiting 20 s after rollback(); naive policy in a probe tuned for frequent natural merges: 40 of 40 commits stall 500 ms, 199 files open
8.7.0, 8.11.4, 9.0.0, 9.8.0, 9.12.3, 10.0.0, 10.5.2; main ea57360912ed affectedthe finder's JUnit (or its pre-9.1 port): 4 of 4 fail
8.6.0, 8.6.3 commit path3 of 3 fail (no getReader merges before 8.7.0); an 8.6.0 control with a registrable spec passes
8.5.2 not affectedno findFullFlushMerges: the test does not compile
upstream main 2f424fd428da readthe same files as 645d4fe14a20

Independent checks

Reproduced by the finder, an independent reproducer, an intended-behaviour devil's advocate with a naive-policy probe, the adjudication, the registry re-runs and audit, the repro-minimization step and this doc. Reviews by OpenAI GPT-5.6 (reading only) called it real; one review lens ended without a verdict, which is not a refutation.

Checks and verdicts419 words

Every reproduction ran on stock builds; fix checks ran with the patched IndexWriter. The cross-model reviews only read code.

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidate4-test JUnit fails 4 of 4 on main (-ea, -da) and five releases; the exception fix passes 4 of 4 and 259 upstream core tests.
Cross-model review (OpenAI GPT-5.6, reading only) real, 0.96Root cause and trigger legality agreed; its points were handled (running-merge test, wording, exception fix, LFV-207 relation).
Independent reproducer realOwn JUnit from the claim only: 4 of 5 fail on main (-ea, -da), 10.5.2 and 9.12.3; the control passes; the exception fix passes 5 of 5.
Adversarial cross-model review (OpenAI GPT-5.6, reading only) intended lens: real, medium; refute lens: no verdictThe refute lens ended without a verdict, which is not a refutation. The intended lens weighed expert API and deliberate silent refusal for natural merges against the javadoc and called it real. A separate run re-ran the finder's JUnit: 4 of 4 fail.
Intended-behaviour devil's advocate realHook-free probe with a naive policy, tuned for frequent natural merges: 39-40 of 40 commits stall 500 ms; 193 and 195 files stay open on main, 550 on 10.5.2; the control is clean.
Adjudication confirmed, highThe independent reproduction's JUnit with a new seed: 4 of 5 fail with -ea and -da; class minor (a leak and a missing exception, not a hang).
Registry re-runs and audit (2026-10-26) keepFresh seeds: finder's JUnit 4 of 4; probe 40 of 40 slow with 188 and 174 files open; control clean; fix shadowed 4 of 4 pass.
Repro minimization (2026-10-29) verifiedThe one-test repro fails on 645d4fe14a20 with -ea, -da and a second seed; the exception fix passes.
This doc (2026-10-31) reproduced, fix validatedGradle on 645d4fe14a20 (stock fails, fix passes with 1,396 core index tests), 10.6.0 jars fail, the finder's JUnit and the probe re-run on 645d4fe14a20.
Fact check (2026-11-01) real; wording fixesRe-ran the minimal test on 645d4fe14a20 (-ea, -da, verbose) and 10.6.0 (fails), the fix shadowed in (passes), and the probe with default settings (0 of 200 slow commits). Its five wording fixes are applied here.
Cross-model fact check (OpenAI GPT-5.6, reading only, 2026-11-01) real; wording fixesNo change to the bug, the repro or the fix; its corrections (SerialMergeScheduler, threads, line references, counts, workarounds, fix caveats) are applied here.

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

Finding record

LFV-502, snapshot of 2026-11-06

Registry title
IndexWriter keeps a full-flush merge that registerMerge rejected: commit() does not throw the IllegalStateException that MergePolicy#findFullFlushMerges documents, the rejected merge's readers stay open after IndexWriter.close(), and commit/getReader wait the full maxFullFlushMergeWaitMillis for a merge that never runs
Area
index / IndexWriter point-in-time merges (merge-on-commit, merge-on-getReader: updatePendingMerges, registerMerge, preparePointInTimeMerge, MergeSpecification.await)
Priority
Low, rank 1007: Only a custom findFullFlushMerges that proposes already-merging segments triggers a bounded commit stall and leaked readers.
Impact class
minor
Impact note
Hits only applications whose custom MergePolicy#findFullFlushMerges proposes a segment that is already in a registered merge, or two overlapping merges in one spec (Lucene's TieredMergePolicy and LogMergePolicy, the test and sandbox policies, and the Elasticsearch, OpenSearch and Solr policies checked all skip merging segments): each affected commit or NRT reopen then stalls for the whole maxFullFlushMergeWaitMillis (500 ms by default since 9.3.0, an explicit setting before) instead of failing with the documented IllegalStateException, the rejected merge's segment readers and IndexFileDeleter references stay open past IndexWriter.close() and accumulate under ongoing indexing (188 open files after 40 commits in a hook-free naive-policy probe), and with a long configured wait getReader keeps waiting after another thread's rollback(); every wait is bounded by the configured timeout and interruptible, no scale is needed, and no wrong results or index damage were found (registered merges apply normally; CheckIndex was not run).
Summary
You install a merge policy whose findFullFlushMerges (merge-on-commit or merge-on-getReader) proposes a segment that another merge is already working on, a case whose outcome the javadoc spells out: commit() 'will throw a IllegalStateException'. Instead IndexWriter quietly refuses to register that merge but still opens its segment readers and waits for it, so commit() returns normally only after the full maxFullFlushMergeWaitMillis, the rejected merge's files stay open after IndexWriter.close(), and DirectoryReader.open(writer) keeps waiting even after another thread has called rollback(). The merges that did register are applied normally.
Introduced
7f352a96659b4d304ae3ef0c7a1b62784ce2a406 (main) and its branch_8x backport 9b1c92809e730a70c1d9bf9e5b65a4b521bddc8b, apache/lucene-solr#1617 (LUCENE-8962 'Merge small segments on commit', s1monw, co-authored by msfroh, msokolov and mikemccand) [read: commit diff, git blame and the fact that both earlier implementations were reverted before any release; release boundary exec: 8.5.2 has no API, 8.6.0 fails]: the new on-commit merge takes the spec that updatePendingMerges returns (it ignores registerMerge's boolean, a loop from 2007-2010 that was harmless until #1585, 59efe22ac29, made it return the spec), opens readers for every merge in it and awaits every merge, and the javadoc sentence promising the IllegalStateException is kept. The getReader path came with 8294e1ae2068 / 2f2146aad46b (apache/lucene-solr#1623, 'Merge segments on getReader', 8.7.0). The reverted first implementation 043c5dff6f4 (#1155) threw the documented exception when registerMerge returned false; the reverted #1552 (972c84022ec) was the first version without that check. Since #13017 (47792dfcd3c9 / fc9d6770a55, 9.11.0 and 10.0.0) the rejected merge also holds IndexFileDeleter references [read] (2020-06-27 (#1617 on main and branch_8x); getReader extension 2020-08-24 (#1623))
First affected version
8.6.0 [exec] (commit path; 8.5.2 does not have findFullFlushMerges, MergeTrigger.COMMIT or setMaxCommitMergeWaitMillis [exec: compile error]); the getReader-after-rollback symptom from 8.7.0 [exec] (8.6.3 has no MergeTrigger.GET_READER [exec: compile error])
Affected versions
Every release from 8.6.0 to 10.6.0 and main ea57360912ed. [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. [exec] the finder's JUnit (or its 8.6 / pre-9.1 API variants) fails on 8.6.0 and 8.6.3 (3 of 3, commit path only), 8.7.0, 8.11.4, 9.0.0, 9.8.0, 9.12.3, 10.0.0, 10.5.2 and main -ea and -da (4 of 4), and an 8.6.0 control with a registrable spec passes; the independent reproduction's own JUnit fails 4 of 5 on main (-ea, -da), 10.5.2 and 9.12.3 with its control passing; the intended-behaviour check's hook-free probe fails on main (-ea, -da) and 10.5.2. [read] the code shape (unchecked registerMerge, initMergeReaders over every point-in-time merge, await, no exception) is present at every release tag in the history clone (8.6.0-8.8.1, 9.0.0-9.12.3, 10.0.0-10.5.2; 8.8.2-8.11.3 are not in the clone). Reach: in 8.6.0-9.2.0 the code path runs only with an explicit non-zero wait; since 9.3.0 (LUCENE-10078) merge on full flush is on by default with a 500 ms wait [read]. Not fixed upstream: no commit to IndexWriter.java or MergePolicy.java on main (a9779ef3c193), branch_10x or branch_10_6 since 2026-09-21 [read]; branch_10_6 not executed. All runs on aarch64 Linux with JDK 25
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-10-26, read-only, verdict new; plus the finder's searches and the intended-behaviour check's search with 966 hits, none relevant): a scripted prior-report search 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 (85 queries), 72 GitHub GraphQL searches (method and class names, symptoms, the info-stream strings 'reject merge' and 'already marked for merge'), a REST scan of the title and body of all 16,902 apache/lucene issues and PRs, 11 direct JIRA searches with 7 issues read with comments, the full comment and review threads of lucene-solr #1155, #1222, #1307, #1313, #1552, #1576, #1585, #1601, #1617, #1623 and #1772, and the changed files of all 408 open apache/lucene PRs (16 diffs near this code scanned). No issue, PR, review comment, JIRA entry, list thread, downstream report or web page reports the missing exception, the rejected point-in-time merge being opened and awaited, the leak past close(), the wait for a merge that never runs, or the getReader wait after rollback(). Design history (intent evidence, not reports): lucene-solr #1155 (the first implementation threw the documented exception; in review msfroh and mikemccand chose 'explosive failure'), #1552 (first version without the check, reverted) and #1617 / #1623 (the shipped code; their discussions never mention rejected merges). Open PRs near the code that would not fix it: #12237 (keeps the 'already marked for merge' rejection as return false), #14837 (logging around the commit await), #16571. Related, different: #11126 / LUCENE-10088 and #14483 (too many open files in the merge-on-commit stress tests, traced to blocked flushes; their policy filters merging segments), #14423 (.cfs left open by a test that skips close()), #10445 / LUCENE-9405. Not fixed upstream (main a9779ef3c193, branch_10x, branch_10_6).
Lucene commit tested
main 645d4fe14a20 (2026-10-31): the minimal test fails through gradle in a scratch worktree with assertions on and off, and through the repro runner on main and on the 10.6.0 release jars, both ways; with the recommended fix it passes, and 25 IndexWriter and merge-policy suites (393 tests, assertions on and off), all 215 core org.apache.lucene.index suites (1,396 tests, assertions on), the sandbox TestMergeOnFlushMergePolicy and core's format, ECJ and forbidden-apis checks pass. The finder's 4-test JUnit (files open after close(), getReader after rollback()) and the naive-policy probe were re-run on 645d4fe14a20. Earlier evidence ran on ea57360912ed, whose IndexWriter.java, MergePolicy.java and IndexWriterConfig.java are identical; releases 8.6.0 to 10.5.2 ran in the version-history runs.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2494-2517 (at ea57360912ed) (updatePendingMerges: findFullFlushMerges for COMMIT and GET_READER, then registerMerge(spec.merges.get(i)) for every merge with the boolean result ignored, then 'return spec')
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5153-5183 (at ea57360912ed) (registerMerge javadoc 'Else (the merge conflicts) false is returned'; the 'reject merge ... is already marked for merge' info-stream line and return false, without closing the merge)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:4076-4100 (at ea57360912ed) (preparePointInTimeMerge: initMergeReaders on every merge of the point-in-time spec through getPooledInstance and getReaderForMerge with deleter.incRef; the error path asserts 'merge should be pending but isn't' for every merge of the spec)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3886-3905 (at ea57360912ed) (prepareCommitInternal: mergeScheduler.merge(COMMIT) then pointInTimeMerges.await(maxFullFlushMergeWaitMillis) under commitLock), :727-752 (finishGetReaderMerge: the same await), :2821-2844 (abortMerges completes only pending and running merges)
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:670-687 (at ea57360912ed) (findFullFlushMerges javadoc: 'If a OneMerge in the returned MergeSpecification includes a segment already included in a registered merge, then IndexWriter#commit() or IndexWriter#prepareCommit() will throw a IllegalStateException. Use MergeContext#getMergingSegments() ...'), :547-558 (MergeSpecification.await(timeout): bounded, interruptible)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:108 (at ea57360912ed) (DEFAULT_MAX_FULL_FLUSH_MERGE_WAIT_MILLIS = 500), :483-487 (timed-out full-flush merges 'will still run to completion ... like natural segment merges')
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:634-648, 727-753, 2454-2518, 2586-2590, 2821-2844, 3773-3936, 3950-4110, 5153-5247
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:546-557, 665-714
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:104-108
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:495-497; SerialMergeScheduler.java:32-40; IndexWriter.java:1374-1394
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:347-349; LogMergePolicy.java:604, 638; MergePolicy.java:689-714; IndexWriterConfig.java:493; IndexWriter.java:619, 3858
  • a Lucene main checkout lucene/test-framework/src/java/org/apache/lucene/tests/index/MockRandomMergePolicy.java:161; lucene/sandbox/src/java/org/apache/lucene/sandbox/index/MergeOnFlushMergePolicy.java:64
  • a Lucene main checkout lucene/CHANGES.txt:4081 (LUCENE-10078, 9.3.0: merge on full flush on by default, 500 ms)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:677-687; IndexWriterConfig.java:482-488; IndexWriter.java:4091-4105
  • a Lucene main checkout lucene/test-framework/src/java/org/apache/lucene/tests/index/MockRandomMergePolicy.java:161; lucene/sandbox/src/test/org/apache/lucene/sandbox/index/TestMergeOnFlushMergePolicy.java:106
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3857, 3921-3933, 4091-4105
History
  • a Lucene main checkout lucene/CHANGES.txt:4081, 5554, 5730
Diagrams
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2511-2517, 4078-4089, 3886-3906

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 the executed runs.

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