A merge that IndexWriter refuses to start is still opened and waited for.
- On
commit(),prepareCommit()orDirectoryReader.open(writer), IndexWriter asks the merge policy'sfindFullFlushMergesfor merges of small, just-flushed segments, starts them and waits up tomaxFullFlushMergeWaitMillis(500 ms by default since 9.3.0). - A segment can be in only one merge at a time.
registerMergerefuses a merge if one of its segments is already merging; a refused natural merge simply does not happen. - The
findFullFlushMergesjavadoc says what happens if a policy proposes such a merge anyway:commit()"will throw a IllegalStateException". Policies are told to consultMergeContext#getMergingSegments().
_1 and _2 stay open after close(). Select the still to open it at full size. Generated with AI, checked against the code.- Use a merge policy whose
findFullFlushMergesreturns 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. - Index three one-document segments and call
commit(). You expectIllegalStateException: the javadoc says commit "will throw" when a proposed merge includes a segment already in a registered merge. - 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.
- The refused merge never runs, so its readers are never closed: after
IndexWriter.close(),_1.cfsand_2.cfsare still open. Overlapping merges like these hit every commit; a policy that only ignoresgetMergingSegments()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).
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.
- Refusal ignored.
IndexWriter.updatePendingMergesgets the spec fromfindFullFlushMergesforCOMMITandGET_READER(IndexWriter.java:2494-2497), callsregisterMerge(spec.merges.get(i))for every merge without looking at the boolean result, and returns the whole spec (:2511-2517). - What a refusal is.
registerMergereturns false when one of the merge's segments is already inmergingSegments("reject merge ... is already marked for merge",:5171-5182) or is not in the live infos (:5184-5194). Only an accepted merge goes intopendingMerges(:5206) and getsregisterDone = true(:5247). For natural merges this is harmless: nobody uses the returned spec. - Readers opened anyway.
preparePointInTimeMerge(:3950-4110) runsinitMergeReaderson every merge of the returned spec, throughgetPooledInstance(sci, true)andgetReaderForMerge(..., mr -> deleter.incRef(...))(:4078-4089). A refused merge never runs and is never aborted, so these readers andIndexFileDeleterreferences are never released. - Awaited anyway. Commit:
prepareCommitInternalcallspreparePointInTimeMerge(..., COMMIT, ...)when the wait is above 0 (:3858-3865), thenmergeScheduler.merge(mergeSource, COMMIT)andpointInTimeMerges.await(maxCommitMergeWaitMillis, ...)(:3886-3906), insidesynchronized (commitLock)(:3775). getReader:preparePointInTimeMerge(..., GET_READER, ...)(:634-648) and the same await (:738-739).MergeSpecification.awaitwaits 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. (WithSerialMergeScheduler,mergeScheduler.mergefirst 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.) - Rollback cannot help.
abortMergescompletes only pending and running merges (:2821-2844), sorollback()cannot release a waitinggetReader.rollback()first claims the close (shouldClose,:1374-1394) and then takescommitLock(:2589): a waiting getReader holds no lock it needs, but a stalled commit does, so rollback waits behind it. - 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 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
findFullFlushMergesthat proposes a segment already in a registered merge, or two overlapping merges in one spec.TieredMergePolicyandLogMergePolicyskip merging segments infindMerges(TieredMergePolicy.java:347-349,LogMergePolicy.java:604,:638), and the defaultfindFullFlushMergesis a size-filtered subset offindMerges(MergePolicy.java:689-714).MockRandomMergePolicyand the sandboxMergeOnFlushMergePolicyfilter 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'sCriteriaBasedMergePolicydrops merging segments before it delegates, Solr'sLatestVersionMergePolicydelegates on filtered infos, and in Elasticsearch only one test overridesfindFullFlushMerges. - 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
commitLockmeanwhile. 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 afterclose(); and itsIndexFileDeleterreferences keep this writer from deleting the merged-away files (once the writer is closed, a laterIndexWriteron the directory can delete them). With a long configured wait,DirectoryReader.open(writer)keeps waiting after another thread'srollback()(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()infindFullFlushMergesand never return overlapping merges: the probe's control shows that is clean. Or setsetMaxFullFlushMergeWaitMillis(0), which turns merge on commit and on getReader off altogether (IndexWriterConfig.java:493; commit and getReader runpreparePointInTimeMergeonly when the wait is above 0,IndexWriter.java:3858,:619).
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.
/*
* 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);
}
}
}
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()throwsIllegalStateException, as theMergePolicy#findFullFlushMergesjavadoc says.- Observed
- No exception:
commit()returns normally after waiting about 500 ms for the refused merge ("Expected exception IllegalStateException but no exception was thrown").
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT) and 10.6.0: fail; with the recommended fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run) |
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- The
findFullFlushMergesjavadoc 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), threwIllegalStateException("MergePolicy ... returned merging segments from findFullFlushMerges")whenregisterMergereturned 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:4099asserts "merge should be pending but isn't" for every point-in-time merge. Lucene's tests check throughMockDirectoryWrapperthat 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.
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 thepreparePointInTimeMerge(..., COMMIT, ...)call anddeleter.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 overridableOneMerge.mergeFinished; if that throws, the plain loop stops and later registered merges stay pending. A final patch should use the sameapplyToAllcleanup, and a test with a throwingmergeFinishedwould 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,TestLogMergePolicyand the repro: 25 suites, 393 tests, 0 failures, assertions on and off; all coreorg.apache.lucene.index.*tests: 215 suites, 1,396 tests, 0 failures (assertions on); sandboxTestMergeOnFlushMergePolicy: 6 tests, 0 failures; core'scheckGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMain,forbiddenApisTestpass. 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(),getReaderreturns afterrollback(); the rollback probe leaves no files.
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.
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.
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 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.
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
updatePendingMergesignoresregisterMerge's result whilepreparePointInTimeMergeopens 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();getReaderafterrollback()) 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr), dev@/java-user@/issues@/commits@ lists, Apache JIRA, the web | 2026-10-26 | scripted 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 PRs | no report; design history #1155, #1552, #1617, #1623; related #11126, #14483, #14423, #10445 |
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-31 | gh search for findFullFlushMerges, registerMerge, preparePointInTimeMerge, maxFullFlushMergeWaitMillis; commit history of IndexWriter.java on main | nothing new; last IndexWriter.java commit 2026-09-21 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20; 10.6.0 | affected | minimal test: no exception, assertions on and off |
main 645d4fe14a20 | other symptoms | 2 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 | affected | the finder's JUnit (or its pre-9.1 port): 4 of 4 fail |
| 8.6.0, 8.6.3 | commit path | 3 of 3 fail (no getReader merges before 8.7.0); an 8.6.0 control with a registrable spec passes |
| 8.5.2 | not affected | no findFullFlushMerges: the test does not compile |
upstream main 2f424fd428da | read | the same files as 645d4fe14a20 |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder | candidate | 4-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.96 | Root cause and trigger legality agreed; its points were handled (running-merge test, wording, exception fix, LFV-207 relation). |
| Independent reproducer | real | Own 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 verdict | The 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 | real | Hook-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, high | The 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) | keep | Fresh 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) | verified | The one-test repro fails on 645d4fe14a20 with -ea, -da and a second seed; the exception fix passes. |
| This doc (2026-10-31) | reproduced, fix validated | Gradle 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 fixes | Re-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 fixes | No change to the bug, the repro or the fix; its corrections (SerialMergeScheduler, threads, line references, counts, workarounds, fix caveats) are applied here. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 475 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,534 words
- 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 coreorg.apache.lucene.indexsuites (1,396 tests, assertions on), the sandboxTestMergeOnFlushMergePolicyand core's format, ECJ and forbidden-apis checks pass. The finder's 4-test JUnit (files open afterclose(), getReader afterrollback()) and the naive-policy probe were re-run on645d4fe14a20. Earlier evidence ran onea57360912ed, whoseIndexWriter.java,MergePolicy.javaandIndexWriterConfig.javaare identical; releases 8.6.0 to 10.5.2 ran in the version-history runs.
- 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-5247a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:546-557, 665-714a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:104-108a 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, 3858a 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:64a 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-4105a 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
- TestRejectedFullFlushMerge.java (one test):
repro/LFV-502/src/org/apache/lucene/index/TestRejectedFullFlushMerge.java(51 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed runs.
assets/LFV-502/bug.png(475 KB)
