IndexWriter hands a merge to the merge scheduler in two steps: it marks the merge as running, then logs it. If the log call throws, the scheduler never receives the merge, but the writer still counts it as running, and everything that waits for running merges waits for it.
- Pending and running merges.
IndexWriterkeeps merges the merge policy selected inpendingMerges. The merge scheduler asks for the next one; the writer moves it torunningMerges, and whoever received it must run it or hand it back (mergeFinish). - Waiting for merges.
close()waits until no merge is pending or running (waitForMerges). A rollback aborts pending merges and waits until no merge is running (abortMerges). - After an Error (such as
OutOfMemoryError) during indexing, IndexWriter closes itself: it rolls back, which includesabortMerges. - InfoStream is IndexWriter's debug log, off by default;
setInfoStream(System.out)turns on every component, including the merge scheduler's"MS"messages.
_0 and _1. IndexWriter moves it from pending to running, the "checked out merge" log call throws before any scheduler gets it, and the writer's self-close then waits in abortMerges for a running merge that no thread owns. Select the still to open it at full size. Generated with AI, checked against the code.- Turn on IndexWriter's InfoStream for the merge scheduler (
"MS"), merge every 2 segments (LogDocMergePolicy, merge factor 2) and flush every 2 documents. Add 3 documents: 1 segment is written, 1 document is buffered. - Add a 4th document. Its flush writes segment 2 and a merge of the 2 segments is selected. IndexWriter marks the merge as running, then logs
" checked out merge _0(11.0.0):c2 _1(11.0.0):c2", and that log call throws anOutOfMemoryError. - You expect what the javadoc promises after an Error:
addDocumentrethrows it and the writer closes itself. The same Error thrown earlier in that scheduler call, before the check-out, does exactly that. - Instead
addDocumentis still blocked after 10 s, in the writer's own rollback (abortMerges), waiting for a merge that no thread will run. With assertions on and off, on main and on 10.6.0, and the same on every release run since 8.6.0. - If the logger throws an ordinary exception instead,
addDocumentfails, the writer stays open, andclose()waits the same way, inwaitForMerges.
Applications that turn on IndexWriter's InfoStream for merge messages (off by default) and hit a throw at that one message: an Error such as OutOfMemoryError at that call, or a logging bridge that throws. Both stock merge schedulers; 4 documents suffice. The indexing call or close() then blocks until interrupted; by reading, the last commit is untouched.
Mechanism, terms and numbers734 words
IndexWriterMergeSource.getNextMerge() first calls IndexWriter.getNextMerge(), which moves the next merge from pendingMerges to runningMerges. Only then, when the merge scheduler is verbose (InfoStream enabled for component "MS"), does it build and log " checked out merge " + segString(...). If that statement throws, getNextMerge() throws instead of returning the merge, so no scheduler ever receives it: nobody runs it and nobody calls mergeFinish for it, and it stays in runningMerges. abortMerges() and waitForMerges() wait while runningMerges is not empty.
After an Error (such as an OutOfMemoryError; the repros throw it from the InfoStream, and by reading it can also hit while Lucene builds the message string), the indexing call's tragic handling runs rollbackInternal() -> abortMerges(), which waits for that merge: addDocument blocks until interrupted and the writer does not finish closing itself, although the IndexWriter class javadoc says it "will close itself". After an unchecked exception from the application's InfoStream, addDocument throws, the writer stays open, and close() blocks in waitForMerges() (after a first interrupt, again in the rollback that follows). Both stock merge schedulers are affected; no scale is needed.
The window came with LUCENE-9345 (2020, first released in 8.6.0), which moved this log line out of ConcurrentMergeScheduler.merge's try/finally that called mergeFinish on failure. It is opt-in (the InfoStream is off by default) and rare, so the priority is Low. The recommended fix finishes the merge when the log call throws, as 8.5.x did and as ConcurrentMergeScheduler.merge still does for its own failures.
- Check-out.
IndexWriter.getNextMerge()(IndexWriter.java:2538-2551, synchronized) doespendingMerges.removeFirst()thenrunningMerges.add(merge)(:2547-2548). - The unprotected statement.
IndexWriterMergeSource.getNextMerge()(IndexWriter.java:6859-6869):
MergePolicy.OneMerge nextMerge = writer.getNextMerge(); // pending -> running
if (nextMerge != null) {
if (writer.mergeScheduler.verbose()) { // InfoStream enabled for "MS"
writer.mergeScheduler.message(
" checked out merge " + writer.segString(nextMerge.segments)); // a throw here ...
}
}
return nextMerge; // ... means no caller gets the mergeverbose() is infoStream != null && infoStream.isEnabled("MS") (MergeScheduler.java:93-95). The stock PrintStreamInfoStream, which IndexWriterConfig.setInfoStream(PrintStream) installs, is enabled for every component (PrintStreamInfoStream.java:60-62).
- No caller can hand it back. The throw leaves
getNextMerge()before it returns.ConcurrentMergeScheduler.mergecalls it (ConcurrentMergeScheduler.java:570) before thetrywhosecatch (Throwable t) { mergeSource.onMergeFinished(merge); throw t; }hands back a merge it could not start (:578-595).SerialMergeScheduler.merge(SerialMergeScheduler.java:32-40) has no hand-back. - The waits.
abortMerges()loopswhile (runningMerges.size() + runningAddIndexesMerges.size() != 0) doWait();(IndexWriter.java:2849-2861);waitForMerges()loopswhile (pendingMerges.size() > 0 || runningMerges.size() > 0) doWait();(:2891-2893).doWait()iswait(1000); an interrupt ends it withThreadInterruptedException(:5860-5870). - Error path.
updateDocumentsrecords an Error as tragic and callsmaybeCloseOnTragicEvent()(IndexWriter.java:1630-1641), which setsclosing = truethroughshouldClose(false)and callsrollbackInternal()(:6098-6106,:1374-1393).rollbackInternaltakescommitLock(:2589); theabortMerges()call inrollbackInternalNoCommit()(:2590,:2621) waits. Other threads (executed in a fact-check probe):commit()andprepareCommit()fail at once withAlreadyClosedException, becauseensureOpen()also rejects a closing writer (:4213-4214,:3735-3736,:913-917);rollback()and a laterclose()wait inshouldClosefor the unfinished self-close (:2581,:1387). - Exception path. A non-Error is not tragic (
:1633-1635), so the writer stays open andclose()->shutdown()->waitForMerges()waits; after a first interrupt,shutdown()'s catch callsrollbackInternal()(:1328-1331), whoseabortMerges()waits again, so it takes two interrupts (executed).
A second site with the same gap, by reading: ConcurrentMergeScheduler.MergeThread.run logs "merge thread %s start" after the check-out and before doMerge (ConcurrentMergeScheduler.java:720-726), and its catch (Throwable) (:747-754) neither runs the merge nor calls onMergeFinished/mergeFinish. An unminimised hunt probe reported forceMerge(1) waiting after a throw there; no verifier re-ran it, and the fixes below do not cover it.
A second caller of the same method, by reading (not run): a merge thread that finished its merge calls runOnMergeFinished -> merge(mergeSource, MergeTrigger.MERGE_FINISHED) (ConcurrentMergeScheduler.java:742, :678-686), which checks out the next pending merge through this same getNextMerge(). A throw at "checked out merge" there strands that merge too, but it surfaces in MergeThread.run's catch (Throwable), not in an indexing call: no indexing call needs to see the Error, and a later close() would wait in waitForMerges. The recommended fix covers this caller as well, because it sits inside getNextMerge().
Exposure and likelihood247 words
- Default or opt-in. Opt-in. A new
IndexWriterConfigtakesInfoStream.getDefault()(LiveIndexWriterConfig.java:142), which isInfoStream.NO_OUTPUT(InfoStream.java:57) unless the application changed it withInfoStream.setDefault(:74); thenverbose()is false, the message is never built and the window is empty. - Triggers. An Error such as
OutOfMemoryErrorat this call: every repro injects it from the application's InfoStream, as Lucene's own tests do; by reading (not executed) it can also hit whilesegStringbuilds the message under heap pressure (IndexWriter.java:5845-5849). Or an exception from an application-supplied InfoStream. The stockPrintStreamInfoStreamdoes not throw (aPrintStreamswallowsIOExceptions). - What breaks. After an Error:
addDocumentblocks until the thread is interrupted and the writer does not finish closing itself; meanwhile other threads'commit()fails withAlreadyClosedExceptionandrollback()orclose()wait. After the interrupt,addDocumentthrowsThreadInterruptedException, not theOutOfMemoryError. After an exception: the writer stays open and usable (a probe indexed 4 more documents and committed; a reader saw all 8), butclose()blocks until it is interrupted twice. All executed. The repros did not runCheckIndex; by reading, the last commit is untouched. - Scale. None: 4 empty documents, deterministic, default
ConcurrentMergeSchedulerandSerialMergeScheduleralike. - Existing mitigations. An application that terminates the JVM on
OutOfMemoryErroris not hit by the Error variant; turning the"MS"component off avoids both. - Likelihood. Rare: it needs the opt-in logging and a throw at exactly this statement; no outside report of the hang was found.
One JUnit test, one method, public API only. The stuck addDocument runs on one extra thread so the test can bound the hang with a 10 s join instead of hanging itself; nothing depends on timing.
/*
* 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 org.apache.lucene.document.Document;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.InfoStream;
public class TestErrorLoggingCheckedOutMerge extends LuceneTestCase {
public void testWriterClosesItselfAfterError() throws Exception {
InfoStream infoStream =
new InfoStream() {
@Override
public void message(String component, String message) {
if (message.startsWith(" checked out merge")) {
throw new OutOfMemoryError();
}
}
@Override
public boolean isEnabled(String component) {
return "MS".equals(component);
}
@Override
public void close() {}
};
LogDocMergePolicy mergePolicy = new LogDocMergePolicy();
mergePolicy.setMergeFactor(2);
IndexWriter w =
new IndexWriter(
new ByteBuffersDirectory(),
new IndexWriterConfig()
.setMaxBufferedDocs(2)
.setMergePolicy(mergePolicy)
.setInfoStream(infoStream));
for (int i = 0; i < 3; i++) {
w.addDocument(new Document());
}
// the 4th document flushes a 2nd segment, so LogDocMergePolicy selects a merge to check out
Thread indexer =
new Thread(() -> expectThrows(OutOfMemoryError.class, () -> w.addDocument(new Document())));
indexer.start();
indexer.join(10_000);
assertFalse(
"expected addDocument to throw the Error, still blocked after 10 s", indexer.isAlive());
assertFalse("expected the writer to have closed itself after the Error", w.isOpen());
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests TestErrorLoggingCheckedOutMerge -Ptests.seed=DEADBEEF
- Expected
- The 4th
addDocumentrethrows theOutOfMemoryErrorand the writer closes itself (isOpen()is false). - Observed
AssertionError: expected addDocument to throw the Error, still blocked after 10 sat line 61; the thread waits inIndexWriter.abortMerges, assertions on and off.
More files, environment, full output299 words
The InfoStream is enabled only for "MS" and throws OutOfMemoryError from message() for any message starting with " checked out merge"; only one merge is ever checked out here. On the stock code the test framework also reports the stuck thread as a suite-level ThreadLeakError; that second failure is the same hang (its stack shows the thread in abortMerges), and the framework's interrupt then ends the wait.
Regression test: this same test. It passes with either fix.
The exception variant (not in this test): an IllegalStateException thrown once at the same line makes addDocument fail, keeps the writer open, and close() then blocks in waitForMerges (executed in the hunt's 4-test verifier class on main with assertions on and off, 10.5.2 and 9.12.3).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with either fix: passes. 10.6.0 release jars: fails |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
cp $REPRO/TestErrorLoggingCheckedOutMerge.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestErrorLoggingCheckedOutMerge -Ptests.seed=DEADBEEF
# the same result with -Ptests.asserts=false; about 30 s on the stock code (10 s join + 20 s leak linger)
# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestErrorLoggingCheckedOutMerge > testWriterClosesItselfAfterError FAILED
java.lang.AssertionError: expected addDocument to throw the Error, still blocked after 10 s
at __randomizedtesting.SeedInfo.seed([DEADBEEF:65A6CF6E564F8E86]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.assertTrue(Assert.java:42)
at org.junit.Assert.assertFalse(Assert.java:65)
at org.apache.lucene.index.TestErrorLoggingCheckedOutMerge.testWriterClosesItselfAfterError(TestErrorLoggingCheckedOutMerge.java:61)
TestErrorLoggingCheckedOutMerge > executionError FAILED
com.carrotsearch.randomizedtesting.ThreadLeakError: 1 thread leaked from SUITE scope at org.apache.lucene.index.TestErrorLoggingCheckedOutMerge:
1) Thread[id=21, name=Thread-3, state=TIMED_WAITING, group=TGRP-TestErrorLoggingCheckedOutMerge]
at java.base/java.lang.Object.wait0(Native Method)
at java.base/java.lang.Object.wait(Object.java:389)
at org.apache.lucene.index.IndexWriter.doWait(IndexWriter.java:5868)
at org.apache.lucene.index.IndexWriter.abortMerges(IndexWriter.java:2860)
at org.apache.lucene.index.IndexWriter.rollbackInternalNoCommit(IndexWriter.java:2621)
at org.apache.lucene.index.IndexWriter.rollbackInternal(IndexWriter.java:2590)
at org.apache.lucene.index.IndexWriter.maybeCloseOnTragicEvent(IndexWriter.java:6104)
at org.apache.lucene.index.IndexWriter.updateDocuments(IndexWriter.java:1639)
at org.apache.lucene.index.IndexWriter.updateDocument(IndexWriter.java:1913)
at org.apache.lucene.index.IndexWriter.addDocument(IndexWriter.java:1492)
...
2> INFO: Starting to interrupt leaked threads:
2> INFO: All leaked threads terminated.
# with either fix: 1 test, 0 failures, no leaked thread (assertions on and off)- The javadoc: after an Error "IndexWriter will close itself". The same Error thrown before the check-out does close it.
- Before 8.6.0 this log line sat in a
try/finallythat handed the merge back; a 2020 refactor moved it out. - Lucene's tests throw
OutOfMemoryErrorfromInfoStream.messageto test recovery; LUCENE-1210 fixed this invariant elsewhere in 2008.
The full argument251 words
- The contract.
IndexWriter.java:162-165: "if you hit an Error, or disaster strikes during a checkpoint then IndexWriter will close itself. This is a defensive measure in case any internal state (buffered documents, deletions, reference counts) were corrupted."close()(:1345) is documented as "waiting for any running merges, committing, and closing"; here no merge is running. - The control. The same
OutOfMemoryErrorthrown at"now merge"(ConcurrentMergeScheduler.java:558, logged before the loop that checks merges out), while the merge is still pending, closes the writer with the Error as its tragic exception (executed on main with assertions on and off, 10.5.2 and 9.12.3). - IndexWriter's own rule. The comment at
IndexWriter.java:5110-5111: "Important that tragicEvent is called after mergeFinish, else we hang waiting for our merge thread to be removed from runningMerges".ConcurrentMergeScheduler.mergehands back a merge it cannot start (:592-595). - The tests.
TestIndexWriterExceptions.testOutOfMemoryErrorCausesCloseToFail(LUCENE-1429,TestIndexWriterExceptions.java:1110-1147) andtestOutOfMemoryErrorRollback(:1150) throwOutOfMemoryErrorfromInfoStream.messageand assert that the writer recovers or closes. - The history. In 8.5.2 the line (then
" consider merge ") sat insideConcurrentMergeScheduler.merge'stry { ... } finally { if (!success) writer.mergeFinish(merge); }. LUCENE-9345 (d7e0b906abc, apache/lucene-solr#1451) moved it after the check-out without the hand-back; the PR's two review comments are about theindex(source)string. CHANGES: "LUCENE-1210: Fix deadlock case on hitting an exception while starting a merge when using ConcurrentMergeScheduler" (#2287, "mergeInit hits an exception (eg OOME)"); "LUCENE-1171: Fixed some cases where OOM errors could cause deadlock in IndexWriter".
The devil's-advocate check177 words
The strongest case: InfoStream is a "Debugging API" tagged @lucene.internal (InfoStream.java:24-28); MergeScheduler and MergeSource are @lucene.experimental; the stock InfoStreams do not throw; close() promises no bounded wait; so a throwing logger is misuse of a debugging hook. The refute-lens cross-model reviewer (OpenAI GPT-5.6) argued this and said not real, while agreeing with the root cause.
It fails for the Error variant: the class javadoc's promise covers Errors without exception, wherever they are raised, and an OutOfMemoryError is not a misbehaving logger (by reading it can also hit while Lucene builds this message; the repros inject it from InfoStream.message, as Lucene's own tests do); IndexWriterConfig.setInfoStream(InfoStream) and setInfoStream(PrintStream) are public and untagged (IndexWriterConfig.java:430-446), and nothing says message() must not throw; Lucene's own tests inject Errors through InfoStream.message; the code itself treats a merge left in runningMerges at tragedy time as a hang to avoid. It carries weight for the RuntimeException variant, which needs a throwing logger: present that one as secondary hardening. Verdict: not intended (judge confidence medium because of that variant).
Recommended Finish the merge when the log call throws
In IndexWriterMergeSource.getNextMerge, wrap the log call: on any throw, call writer.mergeFinish(nextMerge) and rethrow. That is what 8.5.x did for this line and what ConcurrentMergeScheduler.merge does for a merge it cannot start. Validated: the repro passes, and so do 244 tests in 11 closest suites.
if (writer.mergeScheduler.verbose()) {
try {
writer.mergeScheduler.message(
" checked out merge " + writer.segString(nextMerge.segments));
} catch (Throwable t) {
// no merge scheduler holds this merge yet: finish it, else abortMerges and
// waitForMerges keep waiting for it in runningMerges
writer.mergeFinish(nextMerge);
throw t;
}
}All options and trade-offs3 options ยท 733 words
Two fixes were validated in a scratch worktree of 645d4fe14a20; both close the demonstrated cases. A drops the merge (the policy can select it again later); B keeps it for a later pass but, by reading, can leave a merge in an already-cleared pending queue when a rollback runs at the same time. Neither covers the MergeThread.run log line (the same gap by reading, not run) or the throw point open PR #16889 would add. By reading, A leaves two small gaps a maintainer may want closed (below): the verbose() check stays outside its try, and the dropped merge is not closed.
One file, +9/-2 in IndexWriterMergeSource.getNextMerge(). It restores the pre-8.6 hand-back for the log call and matches ConcurrentMergeScheduler.merge's own catch (Throwable). mergeFinish removes the merge from runningMerges, releases its segments and calls notifyAll() (IndexWriter.java:5375-5391). Code is added only on the verbose path; it also covers a merge thread's own call of getNextMerge().
if (writer.mergeScheduler.verbose()) {
try {
writer.mergeScheduler.message(
" checked out merge " + writer.segString(nextMerge.segments));
} catch (Throwable t) {
// no merge scheduler holds this merge yet: finish it, else abortMerges and
// waitForMerges keep waiting for it in runningMerges
writer.mergeFinish(nextMerge);
throw t;
}
}Trade-offs
The merge is dropped, not retried; its segments stay unmerged until the merge policy selects them again on a later merge pass (by reading). It does not cover the MergeThread.run site. By reading (not run): (1) not exactly 8.5.2, where the verbose() check (InfoStream.isEnabled("MS")) was inside the protected try as well (8.5.2 ConcurrentMergeScheduler.java:538-541); here it stays outside, so a throw from isEnabled would still strand the merge, and moving the check inside the try closes that. (2) Like ConcurrentMergeScheduler.merge's existing hand-back (onMergeFinished -> mergeFinish), it does not call OneMerge.close, the only place that completes the merge's completion future and calls its mergeFinished hook (MergePolicy.java:192, :285-299): timed waits such as merge-on-commit (IndexWriter.java:739, :3894) return at their timeout, and a caller in the untimed MergeObserver.await() (MergePolicy.java:915, from forceMergeDeletes(false)) for a merge dropped this way would keep waiting. Closing the merge with success=false, as abortMerges does for pending merges (IndexWriter.java:2831, :5133-5136), would avoid that; not written or tested.
What the checks validated
- Stock: the minimal test fails with assertions on and off.
- With the fix: the minimal test passes with assertions on and off, with no leaked thread.
- The hunt's 4-test verifier class (both schedulers, the exception-then-
close()variant, the pre-check-out control): 4 tests, 0 failures. - Closest suites with the fix:
TestIndexWriterExceptions,TestIndexWriterExceptions2,TestConcurrentMergeScheduler,TestIndexWriterMerging,TestIndexWriterForceMerge,TestIndexWriterMergePolicy,TestIndexWriterOnError,TestTragicIndexWriterDeadlock,TestIndexWriterWithThreads,TestIndexWriter,TestMergeSchedulerExternal: 11 classes, 244 tests, 0 failures, 9 skipped. :lucene:core:checkGoogleJavaFormat,ecjLintMainandforbiddenApisMainpass.- Re-run by the independent fact check: the documented gradle command with the fix passes with assertions on and off, and the 4-test verifier class passes 4 of 4.
- Not run: nightly tests, benchmarks; a concurrent
rollback()during the log call (by reading only); a throw fromisEnabled; a test of the completion future.
The hunt's first fix (plus a notifyAll()): on a throw, under the writer lock, runningMerges.remove(nextMerge) then pendingMerges.addFirst(nextMerge), and rethrow.
Trade-offs
It keeps the merge for a later scheduler pass. By reading (not run): if another thread's rollback() is already in abortMerges() (pending queue cleared, waiting for running merges, IndexWriter.java:2824-2861), the merge lands in the cleared queue with its segments still registered, and assert mergingSegments.isEmpty() (:2622-2623) would trip. That is why A is recommended.
What the checks validated
- The minimal test passes with assertions on and off.
- The 4-test verifier class: 4 tests, 0 failures.
- The same 11 closest classes: 244 tests, 0 failures, 9 skipped. Lint was not re-run for this option.
- Earlier: the finder's 2-test repro (2 of 2) and the verifier's version (4 of 4, re-run by the adjudication).
Logging the merge before IndexWriter.getNextMerge() moves it would also close the window (a throw would leave the merge pending, like the control), but the message would have to be built from the queue under the writer's lock. Not written or run.
A fault-injection sweep threw one OutOfMemoryError at each of the 850 InfoStream calls of a small indexing script, one run per call; 9 runs hung at this log call. Code reading then reduced it to a 4-document test on the default merge scheduler.
The method in full109 words
Method: semi-formal (a bounded-exhaustive single-fault sweep, then code reading).
- The hunt unit on error handlers and errors raised through public callbacks ran its sweep against the real lucene-core jar: a 24-operation script with
SerialMergeScheduler, one fresh run per InfoStream call, with anOutOfMemoryErrorthrown at that call. 9 runs at the merge scheduler's message fromIndexWriterMergeSource.getNextMergehung inIndexWriter.abortMerges, with assertions on and off. - The same unit's static scan of catch handlers did not flag the site: no catch handler is involved; the defect is a missing hand-back.
- Code reading reduced it to a 4-document JUnit on the default
ConcurrentMergeScheduler; independent checks then reproduced it (below).
No prior report found in GitHub issues and PRs (incl. the JIRA archive), the Lucene mailing lists or downstream trackers (searched 2026-10-28, re-checked 2026-10-31). LUCENE-1210 (#2287, 2008) fixed the same invariant at another site, a precedent.
Searches4 searches
Verdict new. #10385 (LUCENE-9345) / apache/lucene-solr#1451 introduced the site. #4446 and #11619 show close() stuck in waitForMerges with other, identified causes. Open PR #16889 adds a doStall() in ConcurrentMergeScheduler.merge between the check-out and the protected try; by reading, an interrupt there would strand a merge the same way, outside this fix.
| Where | When | How | Found |
|---|---|---|---|
| scripted prior-report search (GitHub issues/PRs/commits, Elasticsearch, OpenSearch, Solr, dev@, java-user@, issues@, commits@, builds@, JIRA archive) | 2026-10-28 | 4 runs, 150 queries | no report; closest #2287 (same invariant, other site) |
| GitHub, JIRA, lists and web, by hand | 2026-10-28 | 22 GitHub searches, 6 JIRA queries, list phrase searches for "checked out merge", 7 web queries; the file lists of all 411 open PRs | no report; #16889 changes the same loop, not this site |
| GitHub (this brief) | 2026-10-31 | issue search "checked out merge", PR search getNextMerge, the changed files of the 56 PRs updated since 2026-10-28 and the diffs of those touching IndexWriter.java | 0 issues; only #16889 and #633; #16948, #16810, #16571 do not change getNextMerge |
| Closest items (read) | 2026-10-28 | #2287, #10385, #4446, #11619, #12654, #14731, #16889 | none is this bug: a fixed precedent, the introducing change, other hangs with identified causes |
Introduced by LUCENE-9345 (d7e0b906abc, Simon Willnauer, 2020-04-24), which moved this log line out of a protected block; first released in 8.6.0. Executed: 8.5.2 passes; 8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2, 10.6.0 and main fail. Not fixed upstream as of 2026-10-31.
Versions tested5 rows
Introduced by d7e0b906abc (LUCENE-9345 "Separate MergeSchedulder from IndexWriter", apache/lucene-solr#1451), backported to branch_8x as 9598d43bb629. It deleted " consider merge " from ConcurrentMergeScheduler.merge's try/finally (which called writer.mergeFinish(merge) on failure) and added " checked out merge " to the new IndexWriterMergeSource.getNextMerge, after the check-out. git log -S 'checked out merge' on main returns only this commit (with --all also the backport and aad44bc6e36, a 2021 history-migration commit); the later edits (2d6ad2fee6df reformat, #13328 class to record) do not change behaviour.
Containment: git merge-base --is-ancestor 9598d43bb629 is true for 8.6.0 and false for 8.5.2 (cut from branch_8_5).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails with assertions on and off (gradle and standalone runner); passes with either fix |
upstream main 2f424fd428d, 10.6.0 tag | read | same code (IndexWriter.java:6859-6869; :7001-7011 in 10.6.0) |
| 10.6.0 | affected | the minimal test fails on the release jars with assertions on and off (abortMerges) |
10.5.2, 9.12.3, 9.0.0, 8.11.4, 8.6.0, main a9779ef3c193 | affected | a version-neutral test fails: abortMerges after the Error, waitForMerges after the exception |
| 8.5.2 | not affected | the same test passes: the writer closes itself after the Error; close() returns after the exception |
Confirmed by an independent reproducer, an intended-lens cross-model review (OpenAI GPT-5.6), a devil's-advocate verifier, an adjudication and a credibility audit. A refute-lens review said not real on contract grounds while agreeing with the root cause. This doc's gradle run reproduced it and validated both fixes.
Checks and verdicts365 words
Every executed check reproduces the hang; the one not-real record is a reading-only contract argument (answered under "Why it is not intended").
| Check | Verdict | What it found |
|---|---|---|
| Finder (fault-injection sweep, then code reading) | candidate | 9 sweep runs hung at this call; the 2-test JUnit fails on main, 10.5.2, 9.12.3, 8.6.0; the fix passes. |
| Refute review (OpenAI GPT-5.6, reading only) | not real (medium) | Contract argument (InfoStream is an internal debugging API); root cause agreed; a separate re-run of the JUnit failed as claimed. |
| Independent reproducer | real | Own 4-test class: both schedulers and the exception variant fail on main (assertions on and off), 10.5.2, 9.12.3; the pre-check-out control passes. |
| Intended-lens review (OpenAI GPT-5.6, reading only) | real (medium) | The internal-hook case holds only for the exception variant; separate re-runs fail with assertions on and off. |
| Intended-behaviour devil's advocate | real | Misuse case rejected for the Error variant; re-ran main and 10.5.2; corrected "uninterruptible". |
| Adjudication | confirmed (medium) | Re-ran the independent reproduction's class on main (3 of 4 fail, the control passes) and the fix (4 of 4 pass). |
| Credibility audit | keep | The independent reproduction's class on main with a new seed, assertions on and off: 3 of 4 fail, the control passes. |
| This doc (gradle, scratch worktree) | real; fixes validated | Gradle in a scratch worktree of 645d4fe14a20: the minimal test fails on the stock code with assertions on and off; both fixes pass it, the 4-test verifier class and 244 tests in 11 closest suites. The minimal test also fails on the 10.6.0 release jars. |
| Independent fact checks of this doc (one executed, one cross-model with OpenAI GPT-5.6, reading only) | real; corrections applied | Re-ran the gradle command (stock fails, fix A passes, assertions on and off), the minimal test on 10.6.0 with assertions off and the verifier class with and without fix A. Probes corrected one by-reading claim (other threads' commit() fails at once and rollback() waits in shouldClose; they do not wait for commitLock) and showed that close() after the exception takes two interrupts. The cross-model check added two by-reading caveats to fix A (the isEnabled check, the merge's completion future). |
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 521 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,308 words
- Registry title
- If the merge scheduler's InfoStream throws on the log line right after IndexWriter hands out a merge, that merge stays marked as running with no thread to run it, so the writer hangs in its own tragic rollback after an Error, or in close() after an exception (8.6.0 to main)
- Area
- index / IndexWriter merge hand-off to the merge scheduler (IndexWriterMergeSource.getNextMerge, runningMerges)
- Priority
- Low, rank 799: It is the same kind of bug as LFV-208 (a checked-out merge never handed back, so close() or the tragic rollback hangs), but it needs the opt-in merge-scheduler InfoStream to throw at one log statement and only exists since 8.6.0.
- Impact class
- hang
- Impact note
- This hits applications that turn on IndexWriter's InfoStream for the merge-scheduler component 'MS' (off by default) when the logging call throws at the one ' checked out merge ...' statement. Realistically that is an OutOfMemoryError while Lucene builds the message, or an exception from a logging bridge. It happens with both stock schedulers (the default ConcurrentMergeScheduler and SerialMergeScheduler). After an Error, the indexing call never returns, because the writer's self-close waits forever in abortMerges. After an unchecked exception, the writer stays open and close() waits forever in waitForMerges. No scale is needed. Every release from 8.6.0 to 10.5.2 and main is affected [exec 8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2, main], and 10.6.0 by reading.
- Summary
- You turn on IndexWriter's InfoStream for merge-scheduler messages, and the logging call throws once (an OutOfMemoryError, or an exception from the logging backend). It throws on the message the writer prints right after it hands a merge to the merge scheduler. You expect what the IndexWriter javadoc promises: after an Error the writer closes itself, and after an exception close() still finishes, because no merge is actually running. Instead, the merge has already been moved from 'pending' to 'running', and no thread ever runs or finishes it, so after an Error addDocument hangs forever inside the writer's own rollback (abortMerges), and after an exception close() hangs forever in waitForMerges.
- Introduced
- d7e0b906abcbc43d3737224cadcda7d2c795ccb0 (LUCENE-9345 'Separate MergeSchedulder from IndexWriter', apache/lucene-solr#1451 by s1monw, merged 2020-04-24; branch_8x backport 9598d43bb629). It moved the merge log line out of ConcurrentMergeScheduler.merge's protected try/finally, which called writer.mergeFinish when the merge did not start. The line went into the new IndexWriterMergeSource.getNextMerge, after the pending-to-running check-out, with no hand-back [read: git log -S 'checked out merge' returns only this commit; exec brackets it: 8.5.2 not affected, 8.6.0 affected] (2020-04-24 (author date of d7e0b906abc and of the branch_8x backport 9598d43bb62))
- First affected version
- 8.6.0 [exec: both tests hang]. 8.5.2 is not affected [exec]: there, the analogous post-check-out message (' consider merge ...') sat inside CMS.merge's protected try, so the writer closes itself after the Error and close() returns after the exception. 8.5.x shipped from branch_8_5, which does not contain the backport [read: git merge-base --is-ancestor]
- Affected versions
- main a9779ef3c193 [exec -ea and -da]. 10.5.2, 9.12.3, 9.0.0, 8.11.4 and 8.6.0 [exec -ea, default ConcurrentMergeScheduler]. SerialMergeScheduler was executed by the independent reproduction on main, 10.5.2 and 9.12.3; for the other releases it is from code reading. The releases in between that were not run have the same code [read: git blame of the window on main shows only d7e0b906abc plus two edits that do not change behaviour, 2d6ad2fee6df (formatter, 2020-12-23) and 942065cc3e7 (#13328, class to record, 2024-09-11)]. branch_10_6 and upstream main 20ced71c1e6 have the same code [read]; IndexWriterMergeSource.getNextMerge is the same at the 10.6.0 commit 4ae946e5a9b (released 2026-10-29), so 10.6.0 has it by reading. Not affected: 8.5.2 [exec] and earlier [read]. All runs on aarch64 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 disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check; also reached by code reading
- Upstream and prior reports
- none found (the prior-report record verdict new, searched 2026-10-28, read-only). No issue, PR, commit, review comment, list thread, JIRA entry, downstream report or web page names this site (the ' checked out merge' log call after the check-out in IndexWriterMergeSource.getNextMerge), its trigger (a throw from the InfoStream there) or its consequence (a running merge that no thread owns, so abortMerges and waitForMerges wait forever). Searches: 4 scripted prior-report searches (168 queries over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and builds@ lists, and JIRA), 22 manual GitHub searches (apache/lucene, Elasticsearch, OpenSearch, Solr), 6 JIRA queries, 4 list phrase queries, 7 web queries (one throttled and not retried), plus the changed files of all 411 open apache/lucene PRs. Historical analog, a different bug: #2287 (LUCENE-1210, fixed 2008; trigger 'eg OOME') fixed the same invariant at another site (mergeInit), and that fix still holds; this site arrived in 2020 with LUCENE-9345 (apache/lucene-solr#1451), whose review did not discuss it. Symptom family only: #4446 (LUCENE-3373, open since 2011, reported on 3.0.3, nine years before this site existed; its 2022 comments concern pending merges) and #11619 (LUCENE-10583, attributed to the application synchronizing on the Directory). Open PR #16889 changes the same CMS.merge loop but not this site. By reading (not executed), it would add a second throw point (doStall's ThreadInterruptedException) in the same unprotected window.
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner (assertions on: seeds DEADBEEF and 5EED1234; off: DEADBEEF); with either fix it passes. The 10.6.0 release jars fail the minimal test with assertions on and off. A version-neutral test fails on 8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and maina9779ef3c193, and passes on 8.5.2. Upstream main2f424fd428dand the 10.6.0 tag have the same code (read, 2026-10-31).
- Finding record
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:6858-6874 (at a9779ef3c193) (IndexWriterMergeSource.getNextMerge: writer.getNextMerge(), then mergeScheduler.message(' checked out merge ' + segString(...)) when verbose, with no hand-back; onMergeFinished calls mergeFinish)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2536-2551 (at a9779ef3c193) (getNextMerge: pendingMerges.removeFirst(), runningMerges.add(merge)), :2850-2861 (abortMerges waits in doWait while runningMerges is not empty), :2885-2893 (waitForMerges waits while pendingMerges or runningMerges is not empty), :5108-5113 (comment: tragicEvent after mergeFinish 'else we hang waiting for our merge thread to be removed from runningMerges')lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-165 (at a9779ef3c193) (class javadoc: 'if you hit an Error ... IndexWriter will close itself') and :1345 (close() javadoc: 'waiting for any running merges, committing, and closing')lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:566-596 (at a9779ef3c193) (CMS.merge calls mergeSource.getNextMerge() before the try whose catch (Throwable) calls onMergeFinished) and :718-725 (related, unminimised: 'merge thread %s start' logged after the check-out in MergeThread.run)lucene/core/src/java/org/apache/lucene/index/SerialMergeScheduler.java:32-40 (at a9779ef3c193) (getNextMerge, then merge; no hand-back)lucene/core/src/java/org/apache/lucene/util/InfoStream.java:22-29 (at a9779ef3c193) ('Debugging API', @lucene.internal); lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:430-446 (public setInfoStream(InfoStream) and setInfoStream(PrintStream)); lucene/core/src/test/org/apache/lucene/index/TestIndexWriterExceptions.java:1111, 1150, 1980, 2195 (upstream tests that throw OutOfMemoryError or RuntimeException from InfoStream.message)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2538-2551, (at 645d4fe14a20) 6859-6869, 2839-2844, 2849-2861, 2891-2893, 5860-5870, 1630-1641, 6098-6106, 1374-1393, 2576-2583, 2589-2590, 2621, 1312-1339, 4213-4214, 3735-3736, 913-917lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:570, (at 645d4fe14a20) 578-595, 678-698, 720-756, 760-762; SerialMergeScheduler.java:32-40; MergeScheduler.java:93-103lucene/core/src/java/org/apache/lucene/util/PrintStreamInfoStream.java:60-62; (at 645d4fe14a20) index/IndexWriterConfig.java:430-446
- Who is affected
lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:142; (at 645d4fe14a20) util/InfoStream.java:57; index/IndexWriter.java:5845-5849lucene/core/src/java/org/apache/lucene/util/InfoStream.java:57, (at 645d4fe14a20) 64-80 (getDefault, setDefault)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-165, (at 645d4fe14a20) 1345, 5110-5111; ConcurrentMergeScheduler.java:558, 592-595lucene/core/src/test/org/apache/lucene/index/TestIndexWriterExceptions.java:1110-1147, (at 645d4fe14a20) 1150lucene/CHANGES.txt (at 645d4fe14a20) (LUCENE-1210, LUCENE-1171, LUCENE-6872 entries)lucene/core/src/java/org/apache/lucene/util/InfoStream.java:24-28; (at 645d4fe14a20) index/MergeScheduler.java:34, 108; index/IndexWriterConfig.java:430-446
- Suggested fix
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5375-5391, (at 645d4fe14a20) 2622-2623, 2824-2861, 6871-6874, 739, 3894, 2405, 2831, 5133-5136; ConcurrentMergeScheduler.java:592-595, 678-686, 742; MergeScheduler.java:93-95; MergePolicy.java:192, 285-299, 915
- History
a Lucene clone@origin/main 2f424fd428d and releases/lucene/10.6.0 lucene/core/src/java/org/apache/lucene/index/IndexWriter.java (read, 2026-10-31)
- TestErrorLoggingCheckedOutMerge.java (one test):
repro/LFV-844/src/org/apache/lucene/index/TestErrorLoggingCheckedOutMerge.java(65 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed minimal test.
assets/LFV-844/bug.png(521 KB)
