All findings · Lucene finding · LFV-844 · getnextmerge-logging-strands-checked-out-merge

IndexWriter blocks in its own rollback until interrupted after an Error thrown while logging a checked-out merge

With IndexWriter's InfoStream on for merge messages, a throw from the log call right after a merge is checked out (say an OutOfMemoryError) leaves that merge "running" with no thread. The writer's self-close after the Error, or close() after an exception, then waits for it until interrupted.

Summary

Priority
Low, rank 799It 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
hang
Since
8.6.0 (LUCENE-9345, 2020)introduced 2020-04-24 · about 6 years
Affected
8.6.0 through 10.6.0 and main (6 of these releases and main executed); 8.5.2 not affected
Status
documented
Found by
semi-formal: a single-fault injection sweep through the InfoStream, then code reading

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

The finding in 30 seconds

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.

Background

  1. Pending and running merges. IndexWriter keeps merges the merge policy selected in pendingMerges. The merge scheduler asks for the next one; the writer moves it to runningMerges, and whoever received it must run it or hand it back (mergeFinish).
  2. 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).
  3. After an Error (such as OutOfMemoryError) during indexing, IndexWriter closes itself: it rolls back, which includes abortMerges.
  4. InfoStream is IndexWriter's debug log, off by default; setInfoStream(System.out) turns on every component, including the merge scheduler's "MS" messages.
Diagram of the bug: IndexWriter moves a merge of segments _0 and _1 from pending to running, then the "checked out merge" log call throws an OutOfMemoryError before the merge scheduler receives the merge. The writer's self-close after the Error runs abortMerges, which waits while the merge is still listed as running, but no thread runs it, so addDocument does not return.
The 4th document's flush selects a merge of _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.

The finding, step by step

  1. 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.
  2. 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 an OutOfMemoryError.
  3. You expect what the javadoc promises after an Error: addDocument rethrows it and the writer closes itself. The same Error thrown earlier in that scheduler call, before the check-out, does exactly that.
  4. Instead addDocument is 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.
  5. If the logger throws an ordinary exception instead, addDocument fails, the writer stays open, and close() waits the same way, in waitForMerges.

Who is affected

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.

The code (main 645d4fe14a20)

  1. Check-out. IndexWriter.getNextMerge() (IndexWriter.java:2538-2551, synchronized) does pendingMerges.removeFirst() then runningMerges.add(merge) (:2547-2548).
  2. 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 merge

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

  1. No caller can hand it back. The throw leaves getNextMerge() before it returns. ConcurrentMergeScheduler.merge calls it (ConcurrentMergeScheduler.java:570) before the try whose catch (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.
  2. The waits. abortMerges() loops while (runningMerges.size() + runningAddIndexesMerges.size() != 0) doWait(); (IndexWriter.java:2849-2861); waitForMerges() loops while (pendingMerges.size() > 0 || runningMerges.size() > 0) doWait(); (:2891-2893). doWait() is wait(1000); an interrupt ends it with ThreadInterruptedException (:5860-5870).
  3. Error path. updateDocuments records an Error as tragic and calls maybeCloseOnTragicEvent() (IndexWriter.java:1630-1641), which sets closing = true through shouldClose(false) and calls rollbackInternal() (:6098-6106, :1374-1393). rollbackInternal takes commitLock (:2589); the abortMerges() call in rollbackInternalNoCommit() (:2590, :2621) waits. Other threads (executed in a fact-check probe): commit() and prepareCommit() fail at once with AlreadyClosedException, because ensureOpen() also rejects a closing writer (:4213-4214, :3735-3736, :913-917); rollback() and a later close() wait in shouldClose for the unfinished self-close (:2581, :1387).
  4. Exception path. A non-Error is not tragic (:1633-1635), so the writer stays open and close() -> shutdown() -> waitForMerges() waits; after a first interrupt, shutdown()'s catch calls rollbackInternal() (:1328-1331), whose abortMerges() 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 IndexWriterConfig takes InfoStream.getDefault() (LiveIndexWriterConfig.java:142), which is InfoStream.NO_OUTPUT (InfoStream.java:57) unless the application changed it with InfoStream.setDefault (:74); then verbose() is false, the message is never built and the window is empty.
  • Triggers. An Error such as OutOfMemoryError at 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 while segString builds the message under heap pressure (IndexWriter.java:5845-5849). Or an exception from an application-supplied InfoStream. The stock PrintStreamInfoStream does not throw (a PrintStream swallows IOExceptions).
  • What breaks. After an Error: addDocument blocks until the thread is interrupted and the writer does not finish closing itself; meanwhile other threads' commit() fails with AlreadyClosedException and rollback() or close() wait. After the interrupt, addDocument throws ThreadInterruptedException, not the OutOfMemoryError. After an exception: the writer stays open and usable (a probe indexed 4 more documents and committed; a reader saw all 8), but close() blocks until it is interrupted twice. All executed. The repros did not run CheckIndex; by reading, the last commit is untouched.
  • Scale. None: 4 empty documents, deterministic, default ConcurrentMergeScheduler and SerialMergeScheduler alike.
  • Existing mitigations. An application that terminates the JVM on OutOfMemoryError is 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.

Minimal repro

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.

TestErrorLoggingCheckedOutMerge.java (one test)65 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 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());
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestErrorLoggingCheckedOutMerge -Ptests.seed=DEADBEEF
Expected
The 4th addDocument rethrows the OutOfMemoryError and the writer closes itself (isOpen() is false).
Observed
AssertionError: expected addDocument to throw the Error, still blocked after 10 s at line 61; the thread waits in IndexWriter.abortMerges, assertions on and off.

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

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

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with either fix: passes. 10.6.0 release jars: fails
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
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)

Observed output

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

Why it looks unintended

  • 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/finally that handed the merge back; a 2020 refactor moved it out.
  • Lucene's tests throw OutOfMemoryError from InfoStream.message to 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 OutOfMemoryError thrown 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.merge hands back a merge it cannot start (:592-595).
  • The tests. TestIndexWriterExceptions.testOutOfMemoryErrorCausesCloseToFail (LUCENE-1429, TestIndexWriterExceptions.java:1110-1147) and testOutOfMemoryErrorRollback (:1150) throw OutOfMemoryError from InfoStream.message and assert that the writer recovers or closes.
  • The history. In 8.5.2 the line (then " consider merge ") sat inside ConcurrentMergeScheduler.merge's try { ... } 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 the index(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).

Suggested fix

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.

Finish the merge when the log call throws (validated by the checks) (recommended)

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, ecjLintMain and forbiddenApisMain pass.
  • 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 from isEnabled; a test of the completion future.

Put the merge back into the pending queue (validated by the checks)

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

Log before the check-out (not validated)

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.

How it was found

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 an OutOfMemoryError thrown at that call. 9 runs at the merge scheduler's message from IndexWriterMergeSource.getNextMerge hung in IndexWriter.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).

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search (GitHub issues/PRs/commits, Elasticsearch, OpenSearch, Solr, dev@, java-user@, issues@, commits@, builds@, JIRA archive)2026-10-284 runs, 150 queriesno report; closest #2287 (same invariant, other site)
GitHub, JIRA, lists and web, by hand2026-10-2822 GitHub searches, 6 JIRA queries, list phrase searches for "checked out merge", 7 web queries; the file lists of all 411 open PRsno report; #16889 changes the same loop, not this site
GitHub (this brief)2026-10-31issue 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.java0 issues; only #16889 and #633; #16948, #16810, #16571 do not change getNextMerge
Closest items (read)2026-10-28#2287, #10385, #4446, #11619, #12654, #14731, #16889none is this bug: a fixed precedent, the introducing change, other hangs with identified causes

History

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

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails with assertions on and off (gradle and standalone runner); passes with either fix
upstream main 2f424fd428d, 10.6.0 tag readsame code (IndexWriter.java:6859-6869; :7001-7011 in 10.6.0)
10.6.0 affectedthe 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 affecteda version-neutral test fails: abortMerges after the Error, waitForMerges after the exception
8.5.2 not affectedthe same test passes: the writer closes itself after the Error; close() returns after the exception

Independent checks

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

Each independent check and its verdict
CheckVerdictWhat it found
Finder (fault-injection sweep, then code reading) candidate9 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 realOwn 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 realMisuse 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 keepThe 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 validatedGradle 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 appliedRe-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).

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

Finding record

LFV-844, snapshot of 2026-11-06

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 main a9779ef3c193, and passes on 8.5.2. Upstream main 2f424fd428d and the 10.6.0 tag have the same code (read, 2026-10-31).

Lucene and upstream sources

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-917
  • lucene/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-103
  • lucene/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-5849
  • lucene/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-595
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterExceptions.java:1110-1147, (at 645d4fe14a20) 1150
  • lucene/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)

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 minimal test.

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.