All findings · Lucene finding · LFV-581 · merge-on-commit-serial-scheduler-tragic-merge-deadlock

IndexWriter deadlocks when another thread's merge fails while commit() waits for SerialMergeScheduler

With SerialMergeScheduler, if a merge on one thread fails (say, on a full disk) while another thread's commit() is starting its merge-on-commit merges, the two threads wait for each other's locks forever and the writer can never be closed. Default settings since 9.3.0 suffice.

Summary

Priority
Low, rank 793A permanent deadlock keeps the write lock held until restart, but it needs the non-default SerialMergeScheduler, concurrent commit and indexing, and a merge failure such as a full disk, so it follows the deterministic NoMergeScheduler hang.
Impact
hang
Since
8.6.0 (opt-in); default settings since 9.3.0introduced 2020-06-27 · about 6 years
Affected
every release from 8.6.0 through 10.6.0, and main (9 releases and main executed)
Status
documented
Found by
non-formal baseline: a randomized stress test with injected merge I/O faults hung

AI-assisted page: this finding was first signalled by a testing baseline (randomized, fuzz, property-based or stress testing, or static analysis) 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

Two locks taken in opposite order. commit() holds the writer's commit lock and waits for SerialMergeScheduler's monitor; the failing merge holds that monitor, and the writer's emergency close on that thread waits for the commit lock.

Background

  1. SerialMergeScheduler runs merges on the indexing thread that triggered them, one at a time: its merge() is synchronized, so a second thread that wants to merge waits at the door until the first one is done.
  2. Merge-on-commit (on by default since 9.3.0): commit() asks the merge policy for merges of small segments, hands them to the merge scheduler and waits up to 500 ms for them, all while it holds the writer's commit lock.
  3. Tragic events. When a merge fails, for example on a full disk, IndexWriter closes itself right there, on the merging thread. That close first takes the commit lock, so that it never runs in the middle of a commit.
Diagram of the deadlock: thread 'merger' runs a merge inside SerialMergeScheduler.merge (holding its monitor); thread 'committer' calls commit(), takes IndexWriter's commitLock and waits to enter SerialMergeScheduler.merge for its merge-on-commit merges; the merge fails with a disk-full IOException, IndexWriter's tragic close on the merger thread waits for commitLock; each thread waits for the lock the other holds, forever.
Two threads and two locks: the committer holds the commit lock and waits for SerialMergeScheduler; the merging thread holds SerialMergeScheduler, its merge fails, and its emergency close waits for the commit lock. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Open an IndexWriter with SerialMergeScheduler and otherwise defaults, so commit() waits up to 500 ms for merges of small segments. The index has 3 one-document segments; LogDocMergePolicy merges every 2.
  2. Thread A calls maybeMerge(): SerialMergeScheduler runs a 2-segment merge on thread A, inside its synchronized merge(). The test adds 1 document, then thread B calls commit(): it takes the writer's commit lock, picks a commit merge and waits to enter SerialMergeScheduler.merge.
  3. Thread A's merge now fails with IOException("simulated disk full"). You expect the writer to close itself, both threads to finish and commit() to throw, as with ConcurrentMergeScheduler or with merge-on-commit off.
  4. Instead A's emergency close needs the commit lock that B holds, while B waits for the scheduler that A holds. After 10 s the JVM deadlock detector lists both threads. close() hangs and the write lock stays held until the process restarts.

Who is affected

Applications that set SerialMergeScheduler (not the default) and commit while other threads index: 9.3.0 and later with defaults, 8.6.0-9.2.x only with merge-on-commit opted in. One merge failure (full disk, I/O error) during the commit's wait is enough; no scale needed. The writer then hangs for good and keeps its write lock until restart.

Mechanism, terms and numbers662 words

With merge-on-commit (maxFullFlushMergeWaitMillis > 0, 500 ms by default since 9.3.0), commit() holds IndexWriter's commitLock while it calls mergeScheduler.merge(mergeSource, MergeTrigger.COMMIT). SerialMergeScheduler.merge is synchronized and runs merges on the calling thread, so if another application thread is already inside it running a merge, commit() waits at the monitor, still holding commitLock. If that merge fails (a full disk, any I/O error), IndexWriter treats the failure as tragic and closes itself on the merging thread; the close (rollbackInternal) needs commitLock while that thread still holds the scheduler's monitor. The two threads deadlock: the JVM deadlock detector lists both, Thread.interrupt() does not free them, close() and rollback() from other threads hang, and the write lock stays held until the process restarts; the last commit stays intact. The same failure closes the writer cleanly with ConcurrentMergeScheduler or with merge-on-commit off. The commit-time merge call came with LUCENE-8962 in 8.6.0 (opt-in); LUCENE-10078 made merge-on-commit the default in 9.3.0. A small change in SerialMergeScheduler (a timed lock that checks the writer for a tragedy while it waits, as ConcurrentMergeScheduler already does) makes commit() throw instead; with it the repro passes, and 184 tests in the 14 closest suites and 1,395 tests of the whole org.apache.lucene.index package pass.

The code (main 645d4fe14a20)

  1. Commit holds commitLock across its commit merges. commitInternal takes synchronized (commitLock) (IndexWriter.java:4242) and calls prepareCommitInternal (:4253), which takes it again (:3775), flushes and selects point-in-time merges (preparePointInTimeMerge(toCommit, ..., MergeTrigger.COMMIT, ...), :3863-3864). Then, still under commitLock: eventListener.beginMergeOnFullFlush (:3891), mergeScheduler.merge(mergeSource, MergeTrigger.COMMIT) (:3893), pointInTimeMerges.await(maxCommitMergeWaitMillis, ...) (:3894).
  2. SerialMergeScheduler serializes merges on its own monitor. public synchronized void merge(...) (SerialMergeScheduler.java:32) loops getNextMerge() / mergeSource.merge(merge) on the calling thread (:33-39). Its javadoc says the method is "synchronized" "so that even if the application is using multiple threads, only one merge may run at a time" (:27-29).
  3. A merge failure is tragic and closes the writer on the merging thread. IndexWriter.merge catches any Throwable and calls tragicEvent(t, "merge") (IndexWriter.java:5109-5113), then maybeCloseOnTragicEvent (:6090-6106), then rollbackInternal, which takes synchronized (commitLock) (:2586-2590: "Make sure no commit is running, else e.g. we can close while another thread is still fsync'ing"). The thread is still inside SerialMergeScheduler.merge, holding its monitor.
  4. The cycle. The committer holds commitLock and waits for the scheduler's monitor; the merger holds the monitor and waits for commitLock. Both are blocked on monitor entry, which Thread.interrupt() cannot break.
  5. Why ConcurrentMergeScheduler escapes. LUCENE-10078 made getNextMerge() and hasPendingMerges() throw once the writer has a tragedy (IndexWriter.java:2538-2542, :2558-2562). A committer stalled in ConcurrentMergeScheduler waits in wait(250) and re-checks hasPendingMerges() (ConcurrentMergeScheduler.java:609-636, :647-655), so it throws and releases commitLock. With SerialMergeScheduler the committer never gets past monitor entry.
  6. Lucene avoids this cycle elsewhere. commit() and prepareCommit() run their post-commit maybeMerge outside commitLock: "we must do this outside of the commitLock else we can deadlock" (IndexWriter.java:3738, :4264, LUCENE-7570). getReader waits for its merge-on-refresh merges outside fullFlushLock "since the merge might hit a tragic event and that must not be reported while holding that lock" (:626-632, :738). The merge-on-commit call at :3893 is the one merge call still made under commitLock.

What happens after the deadlock

From the verifiers' probes on main and 10.5.2: Thread.interrupt() leaves both threads BLOCKED; rollback() or close() from a third thread hangs in shouldClose / doWait; a new IndexWriter or CheckIndex on the same path fails with LockObtainFailedException; other threads' addDocument, commit and NRT reopen throw AlreadyClosedException at once; a copy of the last commit opens and passes CheckIndex (the live directory stays locked). With merge-on-commit off, the same failure makes addDocument throw the IOException and commit() throw "this writer hit an unrecoverable error; cannot complete commit". With ConcurrentMergeScheduler, commit() throws "this writer hit an unrecoverable error" and both threads finish.

Exposure and likelihood295 words
  • Who. Applications that configure SerialMergeScheduler and call commit() while another thread's indexing call (addDocument, flush, maybeMerge, forceMerge) runs a merge inside it. Merge-on-commit is on by default since 9.3.0 (500 ms; the stock TieredMergePolicy and LogMergePolicy select small-segment merges at commit). In 8.6.0-9.2.x it needs an explicit nonzero wait and a policy that implements findFullFlushMerges. ConcurrentMergeScheduler, the default, is not affected.
  • Trigger. Any merge failure at that moment: a full disk, another I/O error, or an exception from inside a merge. The window: the other thread's merge must fail while a commit waits at its commit-merge call (IndexWriter.java:3893), after the commit has selected commit merges and before that merge ends.
  • Effect. A permanent hang instead of a clean tragic close: two threads stuck, close() and rollback() hang, the write lock stays held, so the index cannot be written again until the process restarts. Documents added since the last commit are lost, as with the clean close.
  • Not affected. The last commit stays intact: a copy of it opens and passes CheckIndex (the live directory stays locked). No corruption and no wrong search results were seen.
  • How likely. Rare: SerialMergeScheduler with concurrent commits is uncommon and merge failures are rare events. It was first seen unforced in a randomized stress run. Lucene's own randomized tests pick SerialMergeScheduler rarely and always with a merge-on-commit wait of at least 200 ms.
  • Workaround. With SerialMergeScheduler, setMaxFullFlushMergeWaitMillis(0); or ConcurrentMergeScheduler (setMaxMergesAndThreads(1, 1) for one merge at a time, as mikemccand suggested in #6727). Executed: the verifiers' wait-0 and default-ConcurrentMergeScheduler controls, and a setMaxMergesAndThreads(1, 1) run (commit() throws, the writer closes, no deadlock).
  • Not shown. prepareCommit() and close() with commitOnClose reach the same prepareCommitInternal (by reading, not executed).

Minimal repro

One JUnit test, one method, stock SerialMergeScheduler, default merge-on-commit wait, LogDocMergePolicy. Concurrency is the bug, so two threads ordered by latches, no sleeps: a FilterDirectory fails the merge's first output, and an IndexWriterEventListener makes it fail only after commit() has started its commit merges.

TestSerialMergeSchedulerCommitDeadlock.java (one test)120 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 com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FilterDirectory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.tests.util.LuceneTestCase;

@ThreadLeakScope(ThreadLeakScope.Scope.NONE)
public class TestSerialMergeSchedulerCommitDeadlock extends LuceneTestCase {

  public void testMergeFailureDuringMergeOnCommit() throws Exception {
    CountDownLatch merging = new CountDownLatch(1);
    CountDownLatch commitMerging = new CountDownLatch(1);
    Directory dir =
        new FilterDirectory(new ByteBuffersDirectory()) {
          @Override
          public IndexOutput createOutput(String name, IOContext context) throws IOException {
            if (context.context() == IOContext.Context.MERGE) {
              merging.countDown();
              await(commitMerging);
              throw new IOException("simulated disk full");
            }
            return super.createOutput(name, context);
          }
        };
    IndexWriterConfig iwc =
        new IndexWriterConfig()
            .setMergePolicy(NoMergePolicy.INSTANCE)
            .setMergeScheduler(new SerialMergeScheduler())
            .setIndexWriterEventListener(
                new IndexWriterEventListener() {
                  @Override
                  public void beginMergeOnFullFlush(MergePolicy.MergeSpecification merge) {
                    commitMerging.countDown();
                  }

                  @Override
                  public void endMergeOnFullFlush(MergePolicy.MergeSpecification merge) {}
                });
    IndexWriter w = new IndexWriter(dir, iwc);
    for (int i = 0; i < 3; i++) {
      w.addDocument(new Document());
      w.flush();
    }
    LogDocMergePolicy mp = new LogDocMergePolicy();
    mp.setMergeFactor(2);
    mp.setMinMergeDocs(2);
    w.getConfig().setMergePolicy(mp);

    start("merger", w::maybeMerge);
    await(merging);
    w.addDocument(new Document());
    Thread committer = start("committer", w::commit);
    committer.join(10_000);

    ThreadMXBean mx = ManagementFactory.getThreadMXBean();
    long[] ids = mx.findMonitorDeadlockedThreads();
    List<String> deadlocked =
        ids == null
            ? List.of()
            : Arrays.stream(mx.getThreadInfo(ids)).map(ThreadInfo::getThreadName).sorted().toList();
    // expected: commit() and the failed merge both return with an exception; actual: they deadlock
    assertEquals(List.of(), deadlocked);
  }

  private interface IOAction {
    void run() throws IOException;
  }

  private static Thread start(String name, IOAction action) {
    Thread t =
        new Thread(
            () -> {
              try {
                action.run();
              } catch (Exception _) {
              }
            },
            name);
    t.setDaemon(true);
    t.start();
    return t;
  }

  private static void await(CountDownLatch latch) {
    try {
      assertTrue("timed out", latch.await(10, TimeUnit.SECONDS));
    } catch (InterruptedException e) {
      throw new AssertionError(e);
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestSerialMergeSchedulerCommitDeadlock -Ptests.seed=DEADBEEF
Expected
commit() and maybeMerge() both return with an exception (the writer closes on the tragic merge failure), so no thread is deadlocked.
Observed
Both threads stay blocked: AssertionError: expected:<[]> but was:<[committer, merger]> (line 91), with assertions on and off.

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

More files, environment, full output389 words

The two hooks only make the order deterministic: FilterDirectory (@lucene.internal) throws the full-disk stand-in from createOutput for a merge, and IndexWriterEventListener (@lucene.experimental; beginMergeOnFullFlush is called under commitLock just before the commit-merge call, IndexWriter.java:3891) tells the fault when to fire. The threads are daemons and the class is @ThreadLeakScope(NONE) because deadlocked threads cannot be stopped. setMinMergeDocs(2) is needed, else the first merge packs all three segments and the commit has nothing to merge; the policy is switched live after the three flushes so they are not merged early. On stock the test waits 10 s for commit() and then fails; with the fix it passes in under a second. It asserts only that no thread is in a JVM monitor deadlock; what each call returns is shown by a separate outcome check (Fixes). It uses the 10.x IOContext.context() accessor, so it compiles against 10.x and main only; a version-portable main() covers 8.6.0-9.12.3. Its unnamed catch variable (catch (Exception _)) needs Java 22 or later: fine on main, but name the variable in a 10.x source checkout, which compiles with --release 21.

Regression test: this same test (a monitor-deadlock check), next to testDeadlockStalledFullFlushMerges in TestTragicIndexWriterDeadlock (same package).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. 10.6.0 and 10.5.2 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/TestSerialMergeSchedulerCommitDeadlock.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestSerialMergeSchedulerCommitDeadlock -Ptests.seed=DEADBEEF
# the same result with -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestSerialMergeSchedulerCommitDeadlock > testMergeFailureDuringMergeOnCommit FAILED
    java.lang.AssertionError: expected:<[]> but was:<[committer, merger]>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:6E99721BC49A8BED]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:120)
        at org.junit.Assert.assertEquals(Assert.java:146)
        at org.apache.lucene.index.TestSerialMergeSchedulerCommitDeadlock.testMergeFailureDuringMergeOnCommit(TestSerialMergeSchedulerCommitDeadlock.java:91)

# thread stacks at that point (a copy of the test that only adds a stack print, -da;
# test and lambda frames left out; the Object is IndexWriter's commitLock)
merger BLOCKED on java.lang.Object@5afd30d owned by committer
  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.tragicEvent(IndexWriter.java:6094)
  at org.apache.lucene.index.IndexWriter.merge(IndexWriter.java:5112)
  at org.apache.lucene.index.IndexWriter$IndexWriterMergeSource.merge(IndexWriter.java:6884)
  at org.apache.lucene.index.SerialMergeScheduler.merge(SerialMergeScheduler.java:38)
  at org.apache.lucene.index.IndexWriter.executeMerge(IndexWriter.java:2451)
  at org.apache.lucene.index.IndexWriter.maybeMerge(IndexWriter.java:2446)
  at org.apache.lucene.index.IndexWriter.maybeMerge(IndexWriter.java:2439)
committer BLOCKED on org.apache.lucene.index.SerialMergeScheduler@72665766 owned by merger
  at org.apache.lucene.index.SerialMergeScheduler.merge(SerialMergeScheduler.java:34)
  at org.apache.lucene.index.IndexWriter.prepareCommitInternal(IndexWriter.java:3893)
  at org.apache.lucene.index.IndexWriter.commitInternal(IndexWriter.java:4253)
  at org.apache.lucene.index.IndexWriter.commit(IndexWriter.java:4215)

# with the fix: 1 test, 0 failures (assertions on and off), in 0.58 s with assertions on and 0.77 s off, instead of a 10 s wait

Why it looks unintended

  • IndexWriter's own rule: "we must do this outside of the commitLock else we can deadlock" (LUCENE-7570 fixed this lock pair elsewhere).
  • testDeadlockStalledFullFlushMerges requires commit() to throw, not hang, when a merge fails during merge-on-commit; it tests only ConcurrentMergeScheduler.
  • The javadoc's note that commit will "wait for the merge to finish" covers a wait that ends; this one never can.
The full argument179 words
  • The contract. "IndexWriter instances are completely thread safe, meaning multiple threads can call any of its methods, concurrently" (IndexWriter.java:167-170). getTragicException: "If this IndexWriter was closed as a side-effect of a tragic exception, e.g. disk full while flushing a new segment, this returns the root cause exception" (:6108-6111). A disk-full merge is meant to close the writer, not hang it.
  • IndexWriter's own rule. "we must do this outside of the commitLock else we can deadlock" (IndexWriter.java:3738, :4264), from LUCENE-7570 (#8621, 2b073a2f296, 2016: "don't run merges while holding the commitLock to prevent deadlock when merges are stalled and a tragic merge exception strikes"). Merge-on-commit (2020) added a new merge call under commitLock.
  • The test. TestTragicIndexWriterDeadlock.testDeadlockStalledFullFlushMerges (TestTragicIndexWriterDeadlock.java:106-172, LUCENE-10078) asserts that commit() throws "this writer hit an unrecoverable error" when a merge fails during merge-on-commit. It uses ConcurrentMergeScheduler only.
  • A supported combination. SerialMergeScheduler is public and not deprecated, its javadoc names multi-threaded applications (SerialMergeScheduler.java:27-29), IndexWriterConfig.java:495-497 documents it with merge-on-commit, and LuceneTestCase combines the two (LuceneTestCaseParent.java:1710-1711, :1788).
The devil's-advocate check311 words

The strongest case: the setMaxFullFlushMergeWaitMillis javadoc says "If SerialMergeScheduler is used and a non-zero timeout is configured, full-flush merges will always wait for the merge to finish without honoring the configured timeout" (IndexWriterConfig.java:495-497). In the 2020 review of this exact call (apache/lucene-solr#1552), s1monw asked "what if we has a MergeScheduler configured that blocks on this call like SerialMergeScheduler? ... I think we should make sure that this is not trappy", and then concluded "its rather a rare combination ... I think we are fine as it is"; LUCENE-10078 (#921) added the javadoc note. "This blocking in SMS is by design" (mikemccand, #6374, answering s1monw's proposal there to "use a tryLock operation instead of syncing the entire method"), mikemccand also proposed removing SerialMergeScheduler (#6727), and merge scheduling is an expert API.

Why it fails: the note and the review accept a wait for a merge to finish. Here the merge has already failed, and its thread can never leave SerialMergeScheduler.merge, because the tragic close it runs needs the lock the waiting commit holds; nobody in #1552 or #921 discussed a failing merge, the tragic rollback or commitLock. Blocking by design is a wait that ends when the merge ends; the recommended fix keeps that blocking (its tryLock only stops waiting once the writer has a tragedy), so it is not the #6374 change. #6727 is still open (rmuir objected), and the class is public and not deprecated. Lucene fixed this lock pair as a bug at the post-commit merge call (LUCENE-7570), and LUCENE-10078, which turned full-flush merges on by default, added the tragedy checks and a test that make the ConcurrentMergeScheduler variant throw instead of hang. Verdict: not intended. (s1monw flagged this call for SerialMergeScheduler in that review, as a wait, not a deadlock, and proposed a tryLock in #6374; credit them where the issue cites either.)

Suggested fix

Recommended SerialMergeScheduler: timed lock that checks for a tragedy while it waits

In SerialMergeScheduler, replace the synchronized monitor with a ReentrantLock taken by tryLock(250 ms) in a loop that calls mergeSource.hasPendingMerges(), which throws once the writer has a tragedy (as in ConcurrentMergeScheduler's stall loop). commit() then throws "this writer hit an unrecoverable error" and frees the commit lock. Validated: the repro passes, so do 1,395 tests of the org.apache.lucene.index package.

All options and trade-offs5 options · 862 words

One fix was validated in a scratch worktree of 645d4fe14a20; the others are directions, not attempted. The cycle can be broken on either side: the committer must not wait uninterruptibly for the scheduler while it holds commitLock, or the tragic close of a merging thread must not wait for commitLock while it holds the scheduler.

SerialMergeScheduler: timed lock that checks for a tragedy while it waits (validated by the checks) (recommended)

One file, +27/-8 in SerialMergeScheduler.java. Merges stay serial (one ReentrantLock, reentrant like the monitor). A waiting thread retries tryLock(250, MILLISECONDS) and calls mergeSource.hasPendingMerges() in between, which throws IllegalStateException ("this writer hit an unrecoverable error; cannot merge") once the writer has a tragedy (IndexWriter.java:2558-2562): the same check that lets ConcurrentMergeScheduler's stalled committer escape. Without a tragedy the behaviour is unchanged: the committer waits for the other thread's merges, then runs its commit merges. Excerpt below (the new field and merge()).

  private final ReentrantLock mergeLock = new ReentrantLock();

  @Override
  public void merge(MergeSource mergeSource, MergeTrigger trigger) throws IOException {
    // Do not block forever while another thread merges: IndexWriter calls this for merge-on-commit
    // while it holds its commit lock, and if the other thread's merge hits a tragic event, its
    // rollback waits for that commit lock. hasPendingMerges() throws once the writer hit a tragedy.
    try {
      while (mergeLock.tryLock(250, TimeUnit.MILLISECONDS) == false) {
        mergeSource.hasPendingMerges();
      }
    } catch (InterruptedException ie) {
      throw new ThreadInterruptedException(ie);
    }
    try {
      while (true) {
        MergePolicy.OneMerge merge = mergeSource.getNextMerge();
        if (merge == null) {
          break;
        }
        mergeSource.merge(merge);
      }
    } finally {
      mergeLock.unlock();
    }
  }

Trade-offs

  • A subclass whose merge override is itself synchronized keeps the deadlock: it locks the same scheduler object before super.merge reaches the new lock. Lucene's tests have three overrides that call super.merge, two synchronized (TestIndexWriterMergePolicy.java:568, BaseMergePolicyTestCase.java:83) and one not (:631); they pass with the fix, but the two would need synchronized dropped to be covered.
  • Compatibility: SerialMergeScheduler no longer synchronizes on itself, so code that locked the scheduler object to keep merges out would no longer do so (worth a CHANGES note).
  • A waiting thread wakes every 250 ms (the period ConcurrentMergeScheduler.doStall uses).
  • After a tragedy a waiting thread throws at its next check, at most 250 ms later (in the outcome check the committing thread ended 262 ms after it started), instead of after the failing thread's rollback; it is the exception getNextMerge() would throw to it anyway.
  • Interrupting a waiting thread now throws ThreadInterruptedException, as the IndexWriter javadoc describes for waits; the monitor ignored interrupts.
  • It fixes the SerialMergeScheduler case only: a custom MergeScheduler that blocks the same way under commitLock keeps the hazard. The escape relies on hasPendingMerges() throwing after a tragedy, which IndexWriter's own MergeSource does (a method synchronized on the writer).

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 (0.58 s and 0.77 s instead of a 10 s wait), and 10 of 10 iterations with -Ptests.iters=10.
  • Closest suites with the fix: TestTragicIndexWriterDeadlock, TestIndexWriterMergePolicy (its merge-on-commit and merge-on-getReader tests subclass SerialMergeScheduler), TestIndexWriterOnDiskFull, TestIndexWriterCommit, TestIndexWriterMerging, TestIndexWriterExceptions2, TestIndexWriterDelete, TestAddIndexes, TestDeletionPolicy, TestIndexFileDeleter, TestIndexWriterConfig, TestTieredMergePolicy, TestLogMergePolicy, TestIndexWriterOnError: 14 classes, 184 tests, 0 failures, 15 skipped.
  • The whole org.apache.lucene.index package with the fix: 214 classes, 1,395 tests, 0 failures, 56 skipped (the 214 include TestClassloadingDeadlock, a plain JUnit test that Gradle reports under the name "Gradle Test Run :lucene:core:test").
  • :lucene:core:checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain pass.
  • Outcome check (not the repro): commit() returns after 262 ms with "this writer hit an unrecoverable error; cannot merge" (cause: the IOException), maybeMerge() throws the IOException, the writer is closed, and a new IndexWriter opens the same directory.
  • Not run: nightly tests, other modules, benchmarks; one seed (DEADBEEF) for the suites.

SerialMergeScheduler: do not run COMMIT-triggered merges

Return at once for MergeTrigger.COMMIT (one of the options s1monw listed in the apache/lucene-solr#1552 review). The commit then waits at most maxFullFlushMergeWaitMillis and the pending merges run at the next merge call.

Trade-offs

Turns merge-on-commit off for SerialMergeScheduler users and changes behaviour that IndexWriterConfig.java:495-497 documents. Not attempted.

IndexWriter: no commit-time merge call under commitLock

Run the commit's point-in-time merges without holding commitLock, as LUCENE-7570 did for the post-commit merge.

Trade-offs

Fixes every scheduler, but commitLock protects the commit point from prepare to finish (prepareCommit/commit pairing, rollback exclusion); a larger change in a delicate path. Not attempted.

IndexWriter: a tragic close that starts inside a merge does not wait for commitLock

Record the tragedy, leave the scheduler, and close afterwards (or let the commit thread close the writer).

Trade-offs

Changes when the writer closes on a tragedy for every scheduler; LUCENE-6790 added the commitLock wait on purpose ("block commit while rollback runs"). Not attempted.

Workaround for users today

With SerialMergeScheduler, setMaxFullFlushMergeWaitMillis(0); or use ConcurrentMergeScheduler (setMaxMergesAndThreads(1, 1) for one merge at a time, as mikemccand suggested in #6727).

Trade-offs

Executed: the verifiers' wait-0 and default-ConcurrentMergeScheduler controls, and a setMaxMergesAndThreads(1, 1) run; the same failure then closes the writer cleanly.

Downstream note

Downstream: the indexing engines of Elasticsearch and OpenSearch use their own concurrent schedulers, and Solr uses SerialMergeScheduler only if solrconfig.xml selects it (code read, nothing run downstream).

How it was found

A randomized stress test of IndexWriter with injected merge I/O faults and a sequence-number replay oracle hung with SerialMergeScheduler and merge-on-commit; its thread dump showed the lock cycle. The finder reduced it to a latch-driven JUnit; independent AI tools reproduced it and wrote stock-policy versions.

The method in full108 words

Method: non-formal baseline (a randomized stress harness, not a model checker).

  • The harness (stage 3: merge on full flush with SerialMergeScheduler, injected merge I/O faults) hung in round 29 of a local run; its JVM thread dump showed commit() BLOCKED at SerialMergeScheduler.merge from prepareCommitInternal and an indexing thread BLOCKED in rollbackInternal from tragicEvent.
  • The finder reduced it to a deterministic JUnit with a scripted merge policy; an independent reproducer wrote its own main(), and a devil's-advocate verifier wrote a stock-policy JUnit (default wait, LogDocMergePolicy, a simulated "No space left on device"). The minimal test here is that stock-policy test cut down to one method.

Prior reports, history and checks

Prior reports

No prior report found (241 queries, 2026-10-26: GitHub issues and PRs incl. the JIRA archive, the Lucene lists, downstream trackers, the web; all 406 open PRs). Closest: the 2020 review of this call, where s1monw asked whether SerialMergeScheduler makes it "trappy" (a wait, not a deadlock).

Searches3 searches

Verdict new. Related: apache/lucene-solr#1552 (the review question), #8621 (LUCENE-7570, the same lock pair fixed at another call), apache/lucene-solr#1623 (the same hazard avoided for getReader) and #921 (LUCENE-10078, the ConcurrentMergeScheduler test). Open PRs #14837 and #16846 touch nearby code without changing the lock order.

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, web)2026-10-26three runs: identifiers, symptoms and error text, history keys (153 queries)no report; the 'high' hits are generic 2007-2019 commit/rollback issues
By hand: GitHub, JIRA, lists.apache.org, web2026-10-2633 GitHub, 6 JIRA, 30 mailing-list and 19 web searches; 25 related threads readno report; #1552, #1623, #8621, #921 are related context
Open apache/lucene PRs2026-10-26changed files of all 406; patches of the 18 that touch IndexWriter, IndexWriterConfig, LiveIndexWriterConfig or MergeSchedulernone touches SerialMergeScheduler.java or the commit lock order

History

Introduced by LUCENE-8962 (apache/lucene-solr#1617, 7f352a96659, merged 2020-06-27), which added the commit-merge call under commitLock; first released in 8.6.0 (opt-in). On by default since 9.3.0 (LUCENE-10078). Every release from 8.6.0 through 10.6.0 is affected; not fixed upstream as of 2026-10-31.

Versions tested6 rows

Introduced by 7f352a96659 (main) and 9b1c92809e7 (branch_8x), LUCENE-8962 "Merge small segments on commit" (s1monw, with msfroh, msokolov and mikemccand). The other edges are older: SerialMergeScheduler.merge is synchronized since 2.3 (299d6357dd2), a merge failure is tragic since 5.3.0 (LUCENE-6579, 926397824e9), the tragic rollback takes commitLock since 5.4.0 (LUCENE-6790, 690f6d6578b). LUCENE-10078 (#921, b5795db0cf5, 9.3.0) set the default wait to 500 ms and made the stock policies return full-flush merges; from 9.9.0 (#12549) a commit with nothing buffered also runs commit merges.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails with assertions on and off (gradle; 3 seeds in the standalone runner); passes with the fix
upstream main 2f424fd428d, branch_11x, branch_10_6 readsame code (SerialMergeScheduler.java:32; IndexWriter.java:3893, :3935 on branch_10_6)
10.6.0, 10.5.2 affectedthe minimal test fails on the release jars (-ea and -da)
9.3.0, 9.12.3, 10.5.2, main a9779ef3c193 affecteda portable main() deadlocks with default settings and stock LogDocMergePolicy (-ea and -da)
8.6.0, 8.7.0, 8.11.4, 9.0.0, 9.2.0 opt-in onlydeadlocks with merge-on-commit opted in; passes with defaults (merge-on-commit off before 9.3.0)
8.5.2 not affectedno merge-on-commit; passes

Independent checks

Confirmed by an independent reproducer, a devil's-advocate verifier, an adjudication and a credibility audit, each with its own executed test and controls. A cross-model refute review (OpenAI GPT-5.6) could not run code and said not real on procedure only. This doc's gradle run reproduced it and validated the fix.

Checks and verdicts276 words

Every executed check says real. The one not-real record ran no code and agreed with the root cause.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (randomized stress test, then JUnit) candidateThe stress run hung (thread dump saved); the JUnit fails on main -ea/-da, 10.5.2, 9.12.3.
Cross-model review before the candidate was filed (OpenAI GPT-5.6, read-only) real (high)Confirmed the lock cycle by reading; its test-quality point was applied.
Independent reproducer realOwn main() fails on main (20 of 20), 10.5.2, 9.12.3, 9.3.0 and 9.0.0 (opt-in); a wait-0 control passes.
Refute review (OpenAI GPT-5.6, could not execute) not real (low), proceduralCould not run code and agreed with the root cause; the cross-model review's own stock-policy JUnit deadlocks on main and 10.5.2.
Intended-behaviour devil's advocate real (high)Stock-policy JUnit fails on main (5 of 5) and 10.5.2; wait-0 and ConcurrentMergeScheduler controls pass; rollback and interrupt probes.
Adjudication confirmed (high)Re-ran the independent reproduction and the intended-behaviour check (fail; controls pass).
Credibility audit keepRe-ran the stock-policy JUnit with assertions on and off; both controls pass.
Repro minimization verifiedThe one-method test fails on 645d4fe14a20, 3 seeds, -ea/-da; a stack print shows the cycle; a wait-0 copy passes.
This doc real; fix validatedGradle in a scratch worktree of 645d4fe14a20: stock fails with assertions on and off; with the fix the repro passes (and 10 of 10 iterations), 184 tests in 14 closest suites and 1,395 tests of org.apache.lucene.index pass, and core lint passes; the minimal test also fails on the 10.6.0 and 10.5.2 jars.

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

Finding record

LFV-581, snapshot of 2026-11-06

Registry title
With SerialMergeScheduler, a merge that fails while another thread's commit() waits to run its merge-on-commit merges deadlocks both threads forever (commit() holds commitLock and waits for the scheduler's monitor; the failing merge holds that monitor and its tragic rollback waits for commitLock), and the writer can no longer be closed (since 8.6.0; with default merge-on-commit settings since 9.3.0)
Area
index / IndexWriter commit (merge-on-commit under commitLock, SerialMergeScheduler, tragic-event rollback)
Priority
Low, rank 793: A permanent deadlock keeps the write lock held until restart, but it needs the non-default SerialMergeScheduler, concurrent commit and indexing, and a merge failure such as a full disk, so it follows the deterministic NoMergeScheduler hang.
Impact class
hang
Impact note
Hits applications that configure SerialMergeScheduler (not the default; the default ConcurrentMergeScheduler is unaffected, executed control) and call commit() (executed) or prepareCommit() (same code path, by reading) while other threads index, when a merge on an indexing thread fails (a full disk or another I/O error, or a document that makes merges fail through another bug) during the commit's merge-on-commit wait, which is on by default since 9.3.0 (500 ms wait, stock Log/Tiered policies; opt-in in 8.6.0-9.2.x); no scale is needed and it happens with -ea and -da; the commit thread and the merging thread then block forever on each other's monitors (Thread.interrupt does not free them), close() and rollback() from other threads hang, and the write lock stays held until the process restarts, while the last commit stays intact (it opens and passes CheckIndex).
Summary
You configure IndexWriter with SerialMergeScheduler and keep merge-on-commit on (the default since 9.3); one thread calls commit() while another thread's addDocument runs a merge, and that merge fails, for example because the disk is full. You expect IndexWriter to close itself on the tragic merge failure and both calls to return with an exception, as they do with ConcurrentMergeScheduler or with merge-on-commit off. Instead commit() holds IndexWriter's commit lock while it waits to enter SerialMergeScheduler's synchronized merge method, and the failing merge thread, still inside that method, waits for the commit lock in the tragic rollback, so both threads hang forever and the writer cannot be closed.
Introduced
7f352a96659b4d304ae3ef0c7a1b62784ce2a406 (main) and its branch_8x backport 9b1c92809e7 (shipped in 8.6.0): apache/lucene-solr#1617, LUCENE-8962 'Merge small segments on commit' (GitHub issue #10005; author s1monw, co-authors msfroh, msokolov and mikemccand; merged 2020-06-27), which added mergeScheduler.merge(mergeSource, MergeTrigger.COMMIT) to prepareCommitInternal while the caller holds commitLock [read: blame of IndexWriter.java:3893 and git log -S 'MergeTrigger.COMMIT' in the full-history clone; exec at release granularity: 8.5.2 passes (no merge-on-commit), 8.6.0 deadlocks with merge-on-commit opted in]. #1617 added the last edge of the cycle; the others predate it: SerialMergeScheduler.merge synchronized since 2.3 (299d6357dd2), a merge failure tragic since 5.3.0 (LUCENE-6579, 926397824e9), rollbackInternal taking commitLock since 5.4.0 (LUCENE-6790, 690f6d6578b). Two unreleased precursors with the same call (#1155 043c5dff6f4, #1552 972c84022ec) were reverted in 2020. (2020-06-27)
First affected version
8.6.0 [exec: JVM-detected deadlock with merge-on-commit opted in (nonzero commit merge wait and a policy that returns merges from findFullFlushMerges), -ea and -da]; with default merge-on-commit settings and a stock LogDocMergePolicy from 9.3.0 [exec] (LUCENE-10078, #921); 8.5.2 and older not affected [exec 8.5.2: no merge-on-commit, passes]
Affected versions
every release from 8.6.0 through 10.5.2 (the last release before 10.6.0) and main a9779ef3c193; the previous hunt main ea57360912ed too [read: the 7 commits between them touch neither IndexWriter.java, IndexWriterConfig.java nor SerialMergeScheduler.java]. [exec, the follow-up's portable main(), 23 runs] JVM-detected deadlock on 8.6.0 (-ea/-da), 8.7.0, 8.11.4 (-ea/-da), 9.0.0 and 9.2.0 with merge-on-commit opted in, and on 9.3.0, 9.12.3, 10.5.2 and main (each -ea and -da) with default settings and stock LogDocMergePolicy; passes on 8.5.2 (no merge-on-commit), on 8.6.0, 8.11.4 and 9.2.0 with defaults (merge-on-commit off by default before 9.3.0), with stock policies before 9.3.0 (no full-flush merges selected), and on main with merge-on-commit off (control). [exec, JUnits] the finder's JUnit fails on 9.9.0, 9.12.3, 10.5.2 and main (-ea/-da; its 9.1.0 and 9.8.0 failures are a test artifact and not counted, because before 9.9.0 a commit with nothing buffered runs no commit merges); the independent reproduction's main() on main (-ea/-da, 20 of 20 iterations), 10.5.2, 9.12.3, 9.3.0 and 9.0.0 (opt-in); the intended-behaviour check's stock-policy JUnit on main (-ea/-da) and 10.5.2; a separate JUnit on main (-ea/-da) and 10.5.2. Reach by version: 8.6.0-9.2.x needs an explicit nonzero wait, a merge policy implementing findFullFlushMerges and (before 9.9.0) a commit that flushes buffered documents; 9.3.0+ default settings suffice; 9.9.0+ (#12549) also a commit that flushes nothing. [read] a static scan of the tagged releases in the history clone (8.6.0-10.5.2; its 8.9-8.11 tags are missing, 8.11.4 was executed instead) finds the COMMIT merge call under commitLock in each; not executed individually: 8.6.1-8.6.3, 8.8.x-8.11.3, 9.4-9.11.x, 9.12.0-9.12.2, 10.0-10.5.1. Not fixed on upstream main a95c754ee003 (2026-10-26) [read]. Releases were run on 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 could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal baseline: stress
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record, verdict new, high confidence; 241 queries): three scripted prior-report searches (identifiers, symptoms and error text, history keys; 153 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@, commits@ and builds@ lists, the JIRA archive and the web) plus the finder's and the intended-behaviour check's runs, 33 manual GitHub searches, 6 JIRA searches, 30 mailing-list searches, 19 web searches, and the changed files of all 406 open apache/lucene PRs (18 touch IndexWriter.java, IndexWriterConfig.java, LiveIndexWriterConfig.java or MergeScheduler.java; none touches SerialMergeScheduler.java and none changes the commit-path lock order). Related items, none the same bug: the lucene-solr #1552 review thread on the mergeScheduler.merge(mergeSource, MergeTrigger.COMMIT) line (s1monw asked whether a blocking scheduler such as SerialMergeScheduler makes it 'trappy'; the reviewers accepted that commit then waits for the merges, which LUCENE-10078 / #921 later documented as the timeout not being honoured; nobody mentioned a failing merge, the tragic rollback, commitLock or a deadlock); lucene-solr #1623 (merge-on-getReader: the same hazard class was found and fixed for getReader only); #8621 (LUCENE-7570, fixed 2016: the same commitLock / tragic-rollback cycle for the post-commit maybeMerge); #921 / #11116 (LUCENE-10078: added testDeadlockStalledFullFlushMerges, ConcurrentMergeScheduler only); #7276 (LUCENE-6214) and LUCENE-6790 (origins of the tragic-rollback locking); #6374 and #6727 (SerialMergeScheduler blocking by design, deprecation proposal); #12654, #12549, #7728 and #4446 (other merge or tragic-event hangs with different causes); open PRs #14837 and #16846 touch nearby code without changing the lock order.
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 with 3 seeds each; with the recommended fix it passes (in 0.58 s with assertions on and 0.77 s off, instead of a 10 s wait), and 184 tests in the 14 closest suites and 1,395 tests of the whole org.apache.lucene.index package pass. Release jars 10.6.0 and 10.5.2 fail the same test. A version-portable main() with the same steps deadlocks on 8.6.0, 8.7.0, 8.11.4, 9.0.0 and 9.2.0 (merge-on-commit opted in) and on 9.3.0, 9.12.3, 10.5.2 and main a9779ef3c193 (default settings), and passes on 8.5.2. Upstream main 2f424fd428d, branch_11x, branch_10_6 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:3775 (at a9779ef3c193) (prepareCommitInternal: synchronized (commitLock)), :3886-3894 (point-in-time merges: mergeScheduler.merge(mergeSource, MergeTrigger.COMMIT) at :3893, then pointInTimeMerges.await, still under commitLock), :4242-4261 (commitInternal: synchronized (commitLock) around prepareCommitInternal and finishCommit), :4263-4266 ('we must do this outside of the commitLock else we can deadlock', LUCENE-7570), :5108-5113 (IndexWriter.merge: any Throwable -> tragicEvent(t, "merge")), :6090-6106 (tragicEvent -> maybeCloseOnTragicEvent -> rollbackInternal), :2586-2590 (rollbackInternal: synchronized (commitLock)), :2536-2563 (tragedy checks in getNextMerge / hasPendingMerges, LUCENE-10078), :167 ('completely thread safe')
  • lucene/core/src/java/org/apache/lucene/index/SerialMergeScheduler.java:27-40 (at a9779ef3c193) (merge is synchronized at :32 and runs each merge on the calling thread at :38)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:108 (at a9779ef3c193) (DEFAULT_MAX_FULL_FLUSH_MERGE_WAIT_MILLIS = 500), :495-497 (SerialMergeScheduler note: full-flush merges 'will always wait for the merge to finish without honoring the configured timeout')
  • lucene/core/src/test/org/apache/lucene/index/TestTragicIndexWriterDeadlock.java:106-172 (at a9779ef3c193) (testDeadlockStalledFullFlushMerges: commit must throw, not hang; ConcurrentMergeScheduler only)
  • lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:1711, (at a9779ef3c193) :1776-1788 (random SerialMergeScheduler with a nonzero merge-on-commit wait)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3738, (at 645d4fe14a20) :3775, :3863-3864, :3886-3894, :4242-4264, :5109-5113, :6090-6106, :2538-2562, :2586-2590, :626-632, :738
  • lucene/core/src/java/org/apache/lucene/index/SerialMergeScheduler.java:27-40; (at 645d4fe14a20) ConcurrentMergeScheduler.java:609-655
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:108 (at 645d4fe14a20) (DEFAULT_MAX_FULL_FLUSH_MERGE_WAIT_MILLIS = 500)
  • lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:1710-1711, (at 645d4fe14a20) :1788 (random SerialMergeScheduler; wait >= 200 ms)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:167-170, (at 645d4fe14a20) :3738, :4264, :6108-6111; IndexWriterConfig.java:495-497; SerialMergeScheduler.java:27-29
  • lucene/core/src/test/org/apache/lucene/index/TestTragicIndexWriterDeadlock.java:101-172 (at 645d4fe14a20)
Suggested fix
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterMergePolicy.java:568, (at 645d4fe14a20) :631; lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseMergePolicyTestCase.java:83 (SerialMergeScheduler subclasses)
History
  • a Lucene clone: git show origin/main (2f424fd428d), origin/branch_11x, origin/branch_10_6, releases/lucene/10.6.0 of SerialMergeScheduler.java and IndexWriter.java (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.