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.
- SerialMergeScheduler runs merges on the indexing thread that triggered them, one at a time: its
merge()issynchronized, so a second thread that wants to merge waits at the door until the first one is done. - 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. - Tragic events. When a merge fails, for example on a full disk,
IndexWritercloses 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.
- Open an
IndexWriterwithSerialMergeSchedulerand otherwise defaults, socommit()waits up to 500 ms for merges of small segments. The index has 3 one-document segments;LogDocMergePolicymerges every 2. - Thread A calls
maybeMerge():SerialMergeSchedulerruns a 2-segment merge on thread A, inside itssynchronized merge(). The test adds 1 document, then thread B callscommit(): it takes the writer's commit lock, picks a commit merge and waits to enterSerialMergeScheduler.merge. - Thread A's merge now fails with
IOException("simulated disk full"). You expect the writer to close itself, both threads to finish andcommit()to throw, as withConcurrentMergeScheduleror with merge-on-commit off. - 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.
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.
- Commit holds
commitLockacross its commit merges.commitInternaltakessynchronized (commitLock)(IndexWriter.java:4242) and callsprepareCommitInternal(:4253), which takes it again (:3775), flushes and selects point-in-time merges (preparePointInTimeMerge(toCommit, ..., MergeTrigger.COMMIT, ...),:3863-3864). Then, still undercommitLock:eventListener.beginMergeOnFullFlush(:3891),mergeScheduler.merge(mergeSource, MergeTrigger.COMMIT)(:3893),pointInTimeMerges.await(maxCommitMergeWaitMillis, ...)(:3894). - SerialMergeScheduler serializes merges on its own monitor.
public synchronized void merge(...)(SerialMergeScheduler.java:32) loopsgetNextMerge()/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). - A merge failure is tragic and closes the writer on the merging thread.
IndexWriter.mergecatches anyThrowableand callstragicEvent(t, "merge")(IndexWriter.java:5109-5113), thenmaybeCloseOnTragicEvent(:6090-6106), thenrollbackInternal, which takessynchronized (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 insideSerialMergeScheduler.merge, holding its monitor. - The cycle. The committer holds
commitLockand waits for the scheduler's monitor; the merger holds the monitor and waits forcommitLock. Both are blocked on monitor entry, whichThread.interrupt()cannot break. - Why
ConcurrentMergeSchedulerescapes. LUCENE-10078 madegetNextMerge()andhasPendingMerges()throw once the writer has a tragedy (IndexWriter.java:2538-2542,:2558-2562). A committer stalled inConcurrentMergeSchedulerwaits inwait(250)and re-checkshasPendingMerges()(ConcurrentMergeScheduler.java:609-636,:647-655), so it throws and releasescommitLock. WithSerialMergeSchedulerthe committer never gets past monitor entry. - Lucene avoids this cycle elsewhere.
commit()andprepareCommit()run their post-commitmaybeMergeoutsidecommitLock: "we must do this outside of the commitLock else we can deadlock" (IndexWriter.java:3738,:4264, LUCENE-7570).getReaderwaits for its merge-on-refresh merges outsidefullFlushLock"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:3893is the one merge call still made undercommitLock.
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
SerialMergeSchedulerand callcommit()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 stockTieredMergePolicyandLogMergePolicyselect small-segment merges at commit). In 8.6.0-9.2.x it needs an explicit nonzero wait and a policy that implementsfindFullFlushMerges.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()androllback()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:
SerialMergeSchedulerwith 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 pickSerialMergeSchedulerrarely and always with a merge-on-commit wait of at least 200 ms. - Workaround. With
SerialMergeScheduler,setMaxFullFlushMergeWaitMillis(0); orConcurrentMergeScheduler(setMaxMergesAndThreads(1, 1)for one merge at a time, as mikemccand suggested in #6727). Executed: the verifiers' wait-0 and default-ConcurrentMergeSchedulercontrols, and asetMaxMergesAndThreads(1, 1)run (commit()throws, the writer closes, no deadlock). - Not shown.
prepareCommit()andclose()withcommitOnClosereach the sameprepareCommitInternal(by reading, not executed).
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.
/*
* 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);
}
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests TestSerialMergeSchedulerCommitDeadlock -Ptests.seed=DEADBEEF
- Expected
commit()andmaybeMerge()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.
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).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. 10.6.0 and 10.5.2 release jars: fails |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
cp $REPRO/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
# 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- IndexWriter's own rule: "we must do this outside of the commitLock else we can deadlock" (LUCENE-7570 fixed this lock pair elsewhere).
testDeadlockStalledFullFlushMergesrequirescommit()to throw, not hang, when a merge fails during merge-on-commit; it tests onlyConcurrentMergeScheduler.- 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 undercommitLock. - The test.
TestTragicIndexWriterDeadlock.testDeadlockStalledFullFlushMerges(TestTragicIndexWriterDeadlock.java:106-172, LUCENE-10078) asserts thatcommit()throws "this writer hit an unrecoverable error" when a merge fails during merge-on-commit. It usesConcurrentMergeScheduleronly. - A supported combination.
SerialMergeScheduleris public and not deprecated, its javadoc names multi-threaded applications (SerialMergeScheduler.java:27-29),IndexWriterConfig.java:495-497documents it with merge-on-commit, andLuceneTestCasecombines 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.)
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
mergeoverride is itselfsynchronizedkeeps the deadlock: it locks the same scheduler object beforesuper.mergereaches the new lock. Lucene's tests have three overrides that callsuper.merge, twosynchronized(TestIndexWriterMergePolicy.java:568,BaseMergePolicyTestCase.java:83) and one not (:631); they pass with the fix, but the two would needsynchronizeddropped to be covered. - Compatibility:
SerialMergeSchedulerno 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.doStalluses). - 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 theIndexWriterjavadoc describes for waits; the monitor ignored interrupts. - It fixes the
SerialMergeSchedulercase only: a customMergeSchedulerthat blocks the same way undercommitLockkeeps the hazard. The escape relies onhasPendingMerges()throwing after a tragedy, which IndexWriter's ownMergeSourcedoes (a methodsynchronizedon 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 subclassSerialMergeScheduler),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.indexpackage with the fix: 214 classes, 1,395 tests, 0 failures, 56 skipped (the 214 includeTestClassloadingDeadlock, a plain JUnit test that Gradle reports under the name "Gradle Test Run :lucene:core:test"). :lucene:core:checkGoogleJavaFormat,ecjLintMainandforbiddenApisMainpass.- Outcome check (not the repro):
commit()returns after 262 ms with "this writer hit an unrecoverable error; cannot merge" (cause: theIOException),maybeMerge()throws theIOException, the writer is closed, and a newIndexWriteropens the same directory. - Not run: nightly tests, other modules, benchmarks; one seed (DEADBEEF) for the suites.
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.
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.
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.
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: 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).
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 showedcommit()BLOCKED atSerialMergeScheduler.mergefromprepareCommitInternaland an indexing thread BLOCKED inrollbackInternalfromtragicEvent. - 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| scripted prior-report search (GitHub issues/PRs, commits, Elasticsearch, OpenSearch, Solr, dev@, java-user@, issues@, commits@, builds@, JIRA archive, web) | 2026-10-26 | three 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, web | 2026-10-26 | 33 GitHub, 6 JIRA, 30 mailing-list and 19 web searches; 25 related threads read | no report; #1552, #1623, #8621, #921 are related context |
| Open apache/lucene PRs | 2026-10-26 | changed files of all 406; patches of the 18 that touch IndexWriter, IndexWriterConfig, LiveIndexWriterConfig or MergeScheduler | none touches SerialMergeScheduler.java or the commit lock order |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the 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 | read | same code (SerialMergeScheduler.java:32; IndexWriter.java:3893, :3935 on branch_10_6) |
| 10.6.0, 10.5.2 | affected | the minimal test fails on the release jars (-ea and -da) |
9.3.0, 9.12.3, 10.5.2, main a9779ef3c193 | affected | a 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 only | deadlocks with merge-on-commit opted in; passes with defaults (merge-on-commit off before 9.3.0) |
| 8.5.2 | not affected | no merge-on-commit; passes |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (randomized stress test, then JUnit) | candidate | The 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 | real | Own 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), procedural | Could 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 | keep | Re-ran the stock-policy JUnit with assertions on and off; both controls pass. |
| Repro minimization | verified | The 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 validated | Gradle 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 488 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,517 words
- 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 wholeorg.apache.lucene.indexpackage pass. Release jars 10.6.0 and 10.5.2 fail the same test. A version-portablemain()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 maina9779ef3c193(default settings), and passes on 8.5.2. Upstream main2f424fd428d, branch_11x, branch_10_6 and the 10.6.0 tag have the same code (read, 2026-10-31).
- Finding record
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java: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, :738lucene/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-29lucene/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)
- TestSerialMergeSchedulerCommitDeadlock.java (one test):
repro/LFV-581/src/org/apache/lucene/index/TestSerialMergeSchedulerCommitDeadlock.java(120 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed minimal test.
assets/LFV-581/bug.png(488 KB)
