ConcurrentMergeScheduler, the default scheduler, runs each merge in its own background thread. Two limits control it:maxThreadCount(merges that run at once) andmaxMergeCount(merges allowed before the thread asking for one more has to wait).- When
maxMergeCountmerge threads already exist and another merge is pending, the asking thread (an indexing, commit or forceMerge thread) stalls inmaybeStall, napping 250 ms at a time until a merge thread finishes. AUTO_DETECT_MERGES_AND_THREADS(-1) means "pick from the CPU count". The scheduler turns it into numbers ininitialize(), whichIndexWritercalls once, when it opens.
- You open an
IndexWriterwith aConcurrentMergeScheduler(the default). On open, its "auto" limits (-1) become CPU-based numbers: on an 8-core machine, 4 merge threads and 9 merges. - Later, on the open writer, you go back to those defaults with
setMaxMergesAndThreads(AUTO_DETECT_MERGES_AND_THREADS, AUTO_DETECT_MERGES_AND_THREADS). The setter accepts the pair and stores -1 and -1; nothing turns them into numbers again. - You add two one-document segments and call
forceMerge(1). You expect one small merge and a quick return: once the bug is fixed, the same test method takes 0.14 s. - Instead, before starting the merge, the scheduler asks "are 0 running merge threads >= maxMergeCount, -1?". That is always true, so the thread naps 250 ms and asks again, forever. No merge thread starts and nothing is thrown, with assertions on or off.
commit()andclose()hang the same way when they register a merge. Setting concrete limits again (for example 6 and 1) frees the thread at once; from 5.0.0 to 8.5.2 the next merge resolved AUTO by itself.
Applications that change merge limits on an open writer and map an "auto" or "defaults" setting to the public constant AUTO_DETECT_MERGES_AND_THREADS. The default configuration never calls the setter, and AUTO set before the writer opens works. No data or scale is needed; the hang is silent and permanent. Every release since 8.6.0.
Mechanism, terms and numbers591 words
ConcurrentMergeScheduler.setMaxMergesAndThreads accepts the pair (AUTO_DETECT_MERGES_AND_THREADS, AUTO_DETECT_MERGES_AND_THREADS), documented as the "Dynamic default ... based on CPU core count", and stores it as -1/-1. Lucene resolves AUTO on its own only in initialize(), which IndexWriter calls once from its constructor (an explicit setDefaultMaxMergesAndThreads(false) call also sets concrete values). Called on the scheduler of an open writer, the reset leaves both limits at -1. The next thread other than a merge thread that hands pending merges to the scheduler's merge() (forceMerge, commit or close, all three executed) enters maybeStall, whose condition "running merge threads >= maxMergeCount" reads 0 >= -1 and never becomes false, so the thread waits in 250 ms naps forever: no merge thread starts and nothing is thrown, with assertions on and off; the only trace is a verbose InfoStream line. Setting concrete limits again releases it. From 5.0.0 to 8.5.2 merge() resolved AUTO on every call; LUCENE-9345 (8.6.0) moved that call into initialize(). Every release run since, through 10.6.0 and main, hangs.
The mechanism (main 5adff78bb168, lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java)
- The setter stores -1/-1.
setMaxMergesAndThreads(:132-158) takes the AUTO/AUTO pair with the comment// OKand sets both fields to -1 (:133-137). It rejects mixed pairs and values below 1, and its javadoc (:122-131) has no "before the writer is opened" note. - Only
initialize()resolves AUTO by itself.initDynamicDefaults(:442-453) callssetDefaultMaxMergesAndThreads(false)(maxThreadCount = max(1, cores/2),maxMergeCount = maxThreadCount + 5,:190-191) whenmaxThreadCountis AUTO. Its only caller isinitialize()(:533-539), which in production code onlyIndexWritercalls, once, from its constructor (IndexWriter.java:960; a unit test also calls it directly,TestConcurrentMergeScheduler.java:471). The publicsetDefaultMaxMergesAndThreads(false)also sets concrete values, but only when the application calls it. merge()stalls before it starts anything.merge()(:542) loopsif (maybeStall(mergeSource) == false) break;(:566) and only then takes the next merge and starts its thread.- The stall check never passes.
maybeStallloopswhile (mergeSource.hasPendingMerges() && mergeThreadCount() >= maxMergeCount)(:611). WithmaxMergeCount = -1that holds while any merge is pending, so it callsdoStall(), await(250)(:647-654), again and again. A merge thread is never stalled (maybeStallreturns false for it,:623-627). In the repro, where no merge runs, the merge the thread waits for is the one it was about to start itself.
forceMerge(1):Object.wait <- doStall <- maybeStall <- merge <- IndexWriter.executeMerge <- maybeMerge <- forceMerge; after 3 shasPendingMerges()is true and 0 merge threads run;setMaxMergesAndThreads(6, 1)released it after 66 ms and the merge left 1 segment (this doc's probe).commit(): in the post-commitmaybeMerge(the finder's repro,LogDocMergePolicy), or on the merge-on-commit path inprepareCommitInternal, before the new commit point is written (the independent reproducer: the commit generation stays 1).close(): throughIndexWriter.flush(the finder's probe). By reading (not run), an indexing thread whose flush triggers a merge reaches the same call.
- Setting concrete limits again ends the loop (executed: 66 ms).
rollback()from another thread freed the stalledforceMergeafter 7 ms, which then threwAlreadyClosedException(executed, the fact check's probe). By reading it is not a general way out: on the merge-on-commit path the stalled thread holdscommitLock(IndexWriter.java:3775,:3892), whichrollbackInternalneeds first (:2589), and during a stalledclose()arollback()waits for the close to finish (:2581,:1387).
Exposure and likelihood190 words
- Who. Code that changes
ConcurrentMergeSchedulerlimits on a live writer (a normal, tested use: see "Why it is a bug") and resets them with the documented constant, for example to map an "auto" setting back to the defaults. - Default vs opt-in. It needs this Expert setter call on the scheduler of an open writer. The default configuration never calls it; AUTO set before the writer opens is resolved by
initialize(). - Effect. A permanent hang of the next thread (other than a merge thread) that hands pending merges to the scheduler:
forceMerge,commit,close, or (by reading) an indexing thread whose flush triggers a merge. No exception; the only trace is the verboseInfoStreamline "too many merges; stalling..." (ConcurrentMergeScheduler.java:633). - Scale. None: two one-document segments.
- Downstream (read on default branches, not run). Solr calls the setter on a scheduler it has just created, before the writer is built, so
initialize()resolves AUTO; Elasticsearch calls it on a live scheduler, but its settings parse with a minimum of 1, so it never passes -1. Hence likelihood "rare" (a rating from these readings, not a measured prevalence).
One JUnit test method on the public API: open a writer, reset its scheduler to AUTO, index two one-document segments, run forceMerge(1) on a second thread bounded at 10 s. Deterministic, no race; it fails with assertions on and off.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.index;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestCMSLiveResetToAutoDetect extends LuceneTestCase {
public void testResetToAutoDetectOnOpenWriter() throws Exception {
ConcurrentMergeScheduler cms = new ConcurrentMergeScheduler();
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setMergeScheduler(cms))) {
cms.setMaxMergesAndThreads(
ConcurrentMergeScheduler.AUTO_DETECT_MERGES_AND_THREADS,
ConcurrentMergeScheduler.AUTO_DETECT_MERGES_AND_THREADS);
w.addDocument(new Document());
w.flush();
w.addDocument(new Document());
FutureTask<Void> forceMerge =
new FutureTask<>(
() -> {
w.forceMerge(1);
return null;
});
Thread t = new Thread(forceMerge);
t.start();
try {
// expected: forceMerge(1) returns; actual: it waits in maybeStall forever (limits stay -1)
forceMerge.get(10, TimeUnit.SECONDS);
} catch (TimeoutException _) {
fail("forceMerge(1) hangs after resetting a live ConcurrentMergeScheduler to AUTO_DETECT");
} finally {
cms.setMaxMergesAndThreads(6, 1);
t.join();
}
}
}
}
cp $REPRO/TestCMSLiveResetToAutoDetect.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestCMSLiveResetToAutoDetect -Ptests.seed=DEADBEEF
# same failure with -Ptests.asserts=false
- Expected
forceMerge(1)merges the two segments and returns; with the fix the test method takes 0.14 s.- Observed
AssertionError: forceMerge(1) hangs after resetting a live ConcurrentMergeScheduler to AUTO_DETECTafter 10 s, with assertions on and off.
More files, environment, full output265 words
TestCMSLiveResetToAutoDetect (one method, extends LuceneTestCase, package org.apache.lucene.index of the lucene/core tests, next to TestConcurrentMergeScheduler). It uses a ByteBuffersDirectory and a default IndexWriterConfig; the second thread only bounds the hanging call, and the finally sets concrete limits so the test can clean up after a failure. It carries the Apache license header and passes checkGoogleJavaFormat, and it is the regression test unchanged. The outcome does not depend on the seed. catch (TimeoutException _) needs Java 22+, fine on main; a copy for branch_10x (Java 21) needs a named variable.
| lucene | this test: main 5adff78bb168 (12.0.0-SNAPSHOT) and the 10.6.0 jars fail; its forceMerge steps as a main(): 8.6.0 hangs, 8.5.2 passes. Releases with the hunt's commit-path variants: 8.6.0 to 10.6.0 (6 run) fail, 8.5.2 and 7.7.3 pass |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (only this platform was run) |
cp $REPRO/TestCMSLiveResetToAutoDetect.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestCMSLiveResetToAutoDetect -Ptests.seed=DEADBEEF
# gradle, scratch worktree at 5adff78bb168, -Ptests.seed=DEADBEEF -Ptests.asserts=false
TestCMSLiveResetToAutoDetect > testResetToAutoDetectOnOpenWriter FAILED
java.lang.AssertionError: forceMerge(1) hangs after resetting a live ConcurrentMergeScheduler to AUTO_DETECT
at org.apache.lucene.index.TestCMSLiveResetToAutoDetect.testResetToAutoDetectOnOpenWriter(TestCMSLiveResetToAutoDetect.java:51)
# gradle, same, assertions on: the same failure
# with the fix: PASS with assertions on and off
# supporting probe (the repro's steps plus prints), main, -da; stack frames abridged
PROBE after open maxMergeCount/maxThreadCount=7/2 (the test framework randomizes the core count)
PROBE after reset maxMergeCount/maxThreadCount=-1/-1
PROBE after 3 s: forceMerge running, hasPendingMerges=true mergeThreadCount=0 limits=-1/-1
PROBE at org.apache.lucene.index.ConcurrentMergeScheduler.doStall(ConcurrentMergeScheduler.java:650)
PROBE at org.apache.lucene.index.ConcurrentMergeScheduler.maybeStall(ConcurrentMergeScheduler.java:636)
PROBE at org.apache.lucene.index.ConcurrentMergeScheduler.merge(ConcurrentMergeScheduler.java:566)
PROBE at org.apache.lucene.index.IndexWriter.executeMerge(IndexWriter.java:2451)
PROBE at org.apache.lucene.index.IndexWriter.maybeMerge(IndexWriter.java:2446)
PROBE at org.apache.lucene.index.IndexWriter.forceMerge(IndexWriter.java:2281)
PROBE released by setMaxMergesAndThreads(6, 1) after 66 ms; segments=1- The setter accepts the AUTO pair at any time; the constant is the documented CPU-based "Dynamic default", with no before-open restriction.
- Upstream tests change these limits on live writers, and the constant's review asked for the reset to work.
- Up to 8.5.2 the next merge resolved AUTO; a 2020 refactor moved that call without a word about AUTO.
The full argument249 words
- Contract.
AUTO_DETECT_MERGES_AND_THREADSis the "Dynamic default formaxThreadCountandmaxMergeCount, based on CPU core count" (ConcurrentMergeScheduler.java:61-66). The setter takes the pair at any time (// OK,:135). Its@param maxMergeCountsays the incoming thread "will block until a merge thread has completed" when "we already have this many threads running" (:125-128); here no merge thread exists to complete. - Tests.
testLiveMaxMergeCountchanges the limits throughw.getConfig().getMergeScheduler()(TestConcurrentMergeScheduler.java:666),testChangeMaxMergeCountyWhileForceMergechanges them during aforceMerge(:995), and the test framework'smaybeChangeLiveIndexWriterConfigcalls the setter at random on live writers (LuceneTestCaseParent.java:1946-1960, fromRandomIndexWriter), but only with concrete limits (1 to 4 threads,:1950-1951), never AUTO, so randomized tests cannot hit this.testResetToAutoDefault(:873-896) resets to AUTO but only checks the getters of a scheduler no writer uses. - History. When the constant was added (#7013, LUCENE-5951), Chris Hostetter noted in review that the AUTO reset branch was a no-op, and Michael McCandless replied "I'll add a test that exposes this then fix it" (the origin of
testResetToAutoDefault). From 5.0.0 to 8.5.2merge()began withinitDynamicDefaults(writer). LUCENE-9345 (#10385,d7e0b906abc) moved the call into the newinitialize()whenmerge()started to receive aMergeSource, which has noDirectory(the diff turnsinitDynamicDefaults(IndexWriter)intoinitDynamicDefaults(Directory), needed then for the SSD probe, unused since LUCENE-9576 in 9.0.0); its PR, review, commit message and CHANGES entry do not mention AUTO or say why the call moved.
The devil's-advocate check127 words
The strongest case for "not a bug": AUTO is an initial sentinel that initialize() resolves once; the setter is Expert and MergeScheduler is @lucene.experimental, so callers should choose AUTO before opening the writer, or call setDefaultMaxMergesAndThreads(false) to restore defaults.
Why it fails: nothing documents that precondition, the setter accepts the pair without complaint, the reset worked from 5.0.0 to 8.5.2 and was asked for in review, and setDefaultMaxMergesAndThreads(false) is not documented as the way to restore defaults. Even if maintainers decide the reset is unsupported, a silent forever-hang is not a defensible outcome: the setter would have to reject the pair. The argument bears on likelihood, hence priority Low. A credibility audit also rated a silent forever-hang on a legal public call worth a one-line fix.
Recommended Resolve AUTO at the start of merge() again (fix A)
Call initDynamicDefaults(null) again at the start of ConcurrentMergeScheduler.merge() (+4 lines; a no-op unless the limits are AUTO), as merge() did up to 8.5.2. The repro passes with assertions on and off; with the fix the repro and four closest suites run 42 tests (1 skipped) with 0 failures.
public synchronized void merge(MergeSource mergeSource, MergeTrigger trigger) throws IOException {
// setMaxMergesAndThreads(AUTO_DETECT, AUTO_DETECT) may be called after initialize(): resolve it
// here too, otherwise maybeStall compares against -1 and stalls forever
initDynamicDefaults(null);
All options and trade-offs3 options ยท 457 words
Option A was built and checked through gradle on 5adff78bb168; B and C were not built. All three keep the minimal test as the regression test (C would turn it into an expectThrows). Open PR #16889 edits the same merge()/maybeStall code for another bug.
Restore the pre-8.6 behaviour: merge() calls initDynamicDefaults before anything else. It changes nothing unless the limits are AUTO, and since LUCENE-9576 (9.0.0) it no longer uses its Directory argument, so null is safe.
public synchronized void merge(MergeSource mergeSource, MergeTrigger trigger) throws IOException {
// setMaxMergesAndThreads(AUTO_DETECT, AUTO_DETECT) may be called after initialize(): resolve it
// here too, otherwise maybeStall compares against -1 and stalls forever
initDynamicDefaults(null);
Trade-offs
One field check per merge() call, already under the scheduler's lock. Between the reset and the next merge() call the getters still return -1, and the other readers of the two fields (updateMergeThreads, the I/O throttle, the intra-merge executor; ConcurrentMergeScheduler.java:372, :840, :971) see -1 until then (by reading; not exercised by the repro). That matters when a merge is already running at the reset: updateMergeThreads computes doPause = threadIdx < bigMergeCount - maxThreadCount (:372), which with -1 sets the rate of at least the largest running merge to 0 (stopped), and setForceMergeMBPerSec, enableAutoIOThrottle and disableAutoIOThrottle call it (:201, :217, :227; by reading, not run). So fix A is validated for the reproduced case (no merge running at the reset); a reset during a running merge needs its own test, and fix B closes that window.
What the checks validated
- Gradle in a scratch worktree at
5adff78bb168, seedDEADBEEF: the repro fails without the fix (assertions on and off) and passes with it (on and off). - With the fix: the repro,
TestConcurrentMergeScheduler(21),TestIndexWriterMerging(13),TestIndexWriterForceMerge(5, 1 skipped by the suite) andTestMergeSchedulerExternal(2): 42 tests, 0 failures.:lucene:core:checkGoogleJavaFormatpasses.
In setMaxMergesAndThreads, when the pair is AUTO and initialize() has already run, call setDefaultMaxMergesAndThreads(false) right away.
Trade-offs
Every reader sees concrete values at once. Needs an "initialized" flag (or a check of state that initialize() sets); resolving unconditionally would break testResetToAutoDefault, which expects the getters to return AUTO on an unused scheduler.
Throw IllegalStateException for the AUTO pair once the scheduler is initialized, and document that AUTO is an initial value only.
Trade-offs
Loud instead of silent, but it takes away a reset that worked from 5.0.0 to 8.5.2. testResetToAutoDefault (an unused scheduler) is unaffected; the regression test would expect the exception.
Downstream, an engine that let an "auto" merge setting be applied to an open index would hang that index's commits and merges; Solr sets the limits before the writer is built and Elasticsearch never passes -1 (read on their default branches).
Code reading of ConcurrentMergeScheduler: in 8.6.0 the only call that resolves AUTO by itself moved from merge() into the once-per-writer initialize(), while the setter still accepts AUTO at any time. A one-test public-API JUnit then witnessed the hang.
The method in full129 words
Method: non-formal (code reading of ConcurrentMergeScheduler, witnessed by an executable repro).
- The finder's code reading found that
initDynamicDefaults, the only code that resolves AUTO, runs only frominitialize(), whichIndexWritercalls once (the call moved there frommerge()in 8.6.0); the setter accepts AUTO at any time, andmaybeStall's check cannot pass with -1. - The finder then wrote a public-API JUnit test (commit path) that fails on main with assertions on and off and on 10.6.0 and 9.12.3, and a stack probe for the commit and close paths.
- An independent reproducer, two executed cross-model reviews, a devil's-advocate verifier and an adjudication reproduced it with their own tests; a final repro check reduced it to the one-test
forceMergerepro shown here ("Prior reports, history and checks").
No prior report found in apache/lucene issues, PRs and commits (with the JIRA archive), the dev@, java-user@, issues@ and commits@ lists, downstream repositories or the web (searched 2026-11-02). Open PR #16889 edits the same code for another bug and does not fix this.
Searches3 searches
No matching public report was found in the documented searches. Nearest items, none a report of this bug: #7013 (LUCENE-5951, the constant and the review that asked the reset to work), #10385 (LUCENE-9345, the introducing refactor), #10377 (LUCENE-9337, a different hang after a live maxMergeCount change, a thread-accounting race fixed in 8.6.0), #9029 (LUCENE-7980, the error message for a mixed pair), #10616 (LUCENE-9576, a commit hang from the SSD probe on stale NFS: same symptom, other cause), #16889 (open: thread-count overshoot, #13593; same code, no AUTO resolution), and review remarks on #14964 (Michael McCandless, 2025-08-06: a subclass should guard against AUTO "in case it didn't get default'd yet") and #15015 (the shared scheduler inside MultiIndexMergeScheduler needed explicit limits), neither about a live reset. Limits: keyword search; GitHub search does not index all review comments; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive) | 2026-11-02, by the finder, the independent reproducer, the devil's-advocate verifier, the adjudication and the follow-up, re-checked | three scripted prior-report searches (25, 51 and 15 queries: identifiers such as setMaxMergesAndThreads, initDynamicDefaults, maybeStall and AUTO_DETECT_MERGES_AND_THREADS, the upstream tests, the introducing change, symptom phrasings); 24 manual issue and PR searches; every issue or PR updated since 2026-10-31 | no report; #7013, #10385, #10377, #9029 and #10616 are the nearest items |
| Open PRs | 2026-11-02 | the changed files of the open PRs that touch ConcurrentMergeScheduler.java (#16889, #14335, #14953, #14964) | none resolves AUTO; #16889 edits the same merge()/maybeStall code |
| Mailing lists, JIRA, downstream, web | 2026-11-02 | dev@, java-user@, issues@ and commits@; direct JIRA queries over LUCENE and SOLR; Elasticsearch, OpenSearch and Solr repositories; web queries | nothing about this bug |
Introduced by LUCENE-9345 (d7e0b906abc, Simon Willnauer, 2020-04-24), first released in 8.6.0. 8.5.2 and 7.7.3 pass; every release run from 8.6.0 to 10.6.0, and main, hangs. Not fixed upstream.
Versions tested4 rows
Introduced by LUCENE-9345 "Separate MergeSchedulder from IndexWriter" (Simon Willnauer, apache/lucene-solr#1451, migrated issue #10385): d7e0b906abc on main and 9598d43bb62 on branch_8x, author date 2020-04-24. The diff removes initDynamicDefaults(writer) from the start of merge(IndexWriter, ...) and calls initDynamicDefaults(directory) from the new initialize(InfoStream, Directory). First affected release 8.6.0; the constant exists since 5.0.0 (LUCENE-5951), and 5.0.0 to 8.5.2 resolve the reset at the next merge.
A commit-level bisect inside 8.5.2..8.6.0 was not run; of the three ConcurrentMergeScheduler commits in that range only 9598d43bb62 removes the call (by reading). Releases between the executed ones were not run one by one; by reading, no later commit resolves AUTO outside initialize(). Not fixed upstream: no commit has touched ConcurrentMergeScheduler.java since 54283150a81 (2026-06-14, typos).
| Version or commit | Verdict | Result |
|---|---|---|
main 5adff78bb168 | affected | gradle: the repro fails with assertions on and off; passes with fix A |
| 10.5.2, 10.6.0 | JUnit | the independent reproducer's test (and on 10.6.0 the finder's): the second commit does not return within 10 s; on the 10.6.0 jars the minimal repro also fails (fact check) |
| 8.6.0, 8.11.4, 9.0.0, 9.12.3 | main() form | the finder's test as a main() (reset to AUTO, then commit): the second commit does not return within 10 s; the finder's JUnit also fails on 9.12.3; on 8.6.0 a main() with the minimal repro's forceMerge steps also hangs (fact check) |
| 7.7.3, 8.5.2 | main() form | the commit returns: merge() resolves AUTO at the next merge; on 8.5.2 a main() with the minimal repro's forceMerge steps also returns, with limits 6/1 after the merge (fact check) |
A finder, an independent reproducer, two executed cross-model reviews (OpenAI GPT-5.6, refute and intended lenses), a devil's-advocate verifier, an adjudication, an audit, a final repro check and fact checks reproduced it and agree on the root cause. This doc's gradle runs reproduced it on main and validated the fix.
Checks and verdicts375 words
The cross-model reviews ran on main ea57360912ed, whose ConcurrentMergeScheduler.java is identical to 5adff78bb168's; the intended-lens test was re-run on 5adff78bb168 with the same result. The other checks on main ran on 5adff78bb168; the release runs used each release's own jars.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | Own JUnit test (commit path, LogDocMergePolicy) fails on main (assertions on and off), 10.6.0 and 9.12.3; stack probe for the commit and close paths; fix check, TestConcurrentMergeScheduler 21 of 21. |
| independent reproducer | real | Own public-API test outside org.apache.lucene fails on main, 10.6.0, 10.5.2 and 9.12.3 (merge-on-commit path); a concrete 6/1 control passes; 8.5.2 passes and 8.6.0 hangs; the fix makes it pass. |
| cross-model review, refute lens (OpenAI GPT-5.6, executed) | real (high) | Own test stalls only after the live AUTO reset; the concrete-reset sequence completes; 8.5.2 passes and 8.6.0 stalls. |
| cross-model review, intended lens (OpenAI GPT-5.6, executed) | real (high) | 3-case test, re-run on 5adff78bb168: only the live AUTO reset fails; AUTO as an initial-only value is documented nowhere. |
| devil's-advocate verifier | real (high) | forceMerge(1) path with the default config fails on main and 10.6.0 while a concrete control passes; found the LUCENE-5951 review intent. |
| adjudication | confirmed, Low | Re-ran the reproducer's test (assertions on) and the finder's (off): fail; the reproducer's test with the fix: pass. |
| separate re-run and audit | confirmed (fail, fail) | The finder's commit-path test (TestConcurrentMergeSchedulerResetToAutoDetect) fails on main with assertions on and off. |
| final repro check | confirmed | The one-test forceMerge repro shown here fails on main with assertions on and off and passes with the fix. |
| this doc | reproduced; fix A validated | Gradle: fails without the fix, passes with it; with it 42 tests of the closest suites run with 0 failures; a stack probe of the hanging forceMerge. |
| fact checks of this doc (independent; OpenAI GPT-5.6) | real; wording fixed | The independent check re-ran the gradle command (fails on main with assertions on and off, passes with fix A), the minimal repro on the 10.6.0 jars (fails) and its steps on 8.5.2 (pass) and 8.6.0 (hang); the cross-model check re-read the code and the recorded results. Both found wording and attribution errors, fixed here. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 556 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,455 words
- Registry title
- After an IndexWriter has started using a ConcurrentMergeScheduler, setMaxMergesAndThreads(AUTO_DETECT_MERGES_AND_THREADS, AUTO_DETECT_MERGES_AND_THREADS) leaves both limits at -1, so the next commit, close or forceMerge that registers a merge waits in maybeStall forever and no merge thread is ever started
- Area
- index / ConcurrentMergeScheduler merge limits (setMaxMergesAndThreads reset to AUTO_DETECT_MERGES_AND_THREADS, initialize, maybeStall)
- Priority
- Low, rank 791: Resetting a live writer's ConcurrentMergeScheduler to the documented AUTO_DETECT_MERGES_AND_THREADS constant makes the next commit, close or forceMerge that registers a merge hang forever, every time and with no error, in every release since 8.6.0. It needs that Expert setter call on a writer that is already open, which the major downstream engines do not make, so it is rare.
- Impact class
- hang
- Impact note
- Hits only applications that call the Expert setter setMaxMergesAndThreads(AUTO_DETECT_MERGES_AND_THREADS, AUTO_DETECT_MERGES_AND_THREADS) on the scheduler of an IndexWriter that is already open (the default ConcurrentMergeScheduler, any merge policy that proposes a merge, no scale needed): the thread that registers the next merge (commit(), close() through flush and forceMerge, all executed) then waits forever with -ea and -da, with no exception (the only trace is a verbose InfoStream line), until the application sets concrete limits again [exec]; a rollback() from another thread freed a stalled forceMerge [exec: the fact check's probe], but by reading it cannot free a commit stalled on the merge-on-commit path (the stalled thread holds commitLock) or a stalled close(); setting AUTO before the writer is opened works, and the default configuration never calls the setter; every release from 8.6.0 through 10.6.0 and main.
- Summary
- You reset the merge limits of the ConcurrentMergeScheduler that an open IndexWriter uses to the documented automatic default with setMaxMergesAndThreads(AUTO_DETECT_MERGES_AND_THREADS, AUTO_DETECT_MERGES_AND_THREADS), a pair the setter accepts. You expect the scheduler to go back to its CPU-based defaults, as it did up to 8.5.2. Instead both limits stay at -1, because only initialize() (run once, when the writer is opened) resolves AUTO, so the next commit, close or forceMerge that registers a merge waits in maybeStall forever, with no error and no merge thread started, until concrete limits are set again (a rollback() from another thread freed a stalled forceMerge, but by reading it cannot free a stalled commit on the merge-on-commit path or a stalled close()).
- Introduced
- d7e0b906abcbc43d3737224cadcda7d2c795ccb0 on main and 9598d43bb629b0434e7ce557fe12ea88c19a5d00 on branch_8x, the same change: LUCENE-9345 'Separate MergeSchedulder from IndexWriter' (apache/lucene-solr#1451, migrated issue #10385) by Simon Willnauer [read: git show d7e0b906abc in the history clone moves initDynamicDefaults(..) from the start of merge() into the new initialize(); exec: 8.5.2 passes, 8.6.0 fails]. Before it, merge() resolved AUTO on every call (5.0.0 to 8.5.2). A commit-level bisect inside 8.5.2..8.6.0 was not executed; by reading, of the three ConcurrentMergeScheduler commits in that range only 9598d43bb62 removes initDynamicDefaults from merge(). (2020-04-24 (author and committer date of d7e0b906abc and 9598d43bb62; PR merged 2020-04-24); first release 8.6.0)
- First affected version
- 8.6.0 [exec: main() variant fails with -da]; 8.5.2 not affected [exec: passes]; the constant exists since 5.0.0 (LUCENE-5951), so 5.0.0 to 8.5.2 resolve the reset at the next merge [read; 7.7.3 and 8.5.2 executed]
- Affected versions
- Every release from 8.6.0 (2020) through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT); never fixed. [exec, seed DEADBEEF unless stated, JDK 25] the finder's one-test commit-path JUnit fails on main (-ea and -da), 10.6.0 (-ea and -da) and 9.12.3 (-ea), and the one-test forceMerge repro on main (-ea and -da) and the 10.6.0 jars (-ea, the fact check); a version-neutral main() variant fails on 8.6.0, 8.11.4, 9.0.0 and 9.12.3 (-da) and passes on 7.7.3 and 8.5.2; the independent reproduction's own test fails on main (-ea and -da), 10.6.0, 10.5.2 and 9.12.3 (-da); the intended-behaviour check's own test fails on main (-ea and -da) and 10.6.0 (-da); re-run on main 2026-11-02 fails with -ea and -da. The releases between the executed ones were not run one by one; by reading, no commit after d7e0b906abc resolves AUTO outside initialize(). Not new in 10.6 (10.5.2 and every executed release from 8.6.0 fail). Not fixed upstream: no commit has touched ConcurrentMergeScheduler.java since 54283150a81 (author date 2026-06-14, typos), and the only open PR on the file updated in the last 48 h (#16889) does not resolve AUTO.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading
- Upstream and prior reports
- none found (2026-11-02, on top of the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's GitHub searches; rechecked the same day): three scripted prior-report searches (25, 51 and 15 queries: identifiers such as setMaxMergesAndThreads, initDynamicDefaults, maybeStall and AUTO_DETECT_MERGES_AND_THREADS, the upstream tests, the introducing change and symptom phrasings) over apache/lucene issues, PRs and commits, downstream repositories (Elasticsearch, OpenSearch, Solr), the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 24 manual apache/lucene issue and PR searches; the changed files of the open PRs that touch ConcurrentMergeScheduler.java (#16889, #14335, #14953, #14964) and of every apache/lucene issue or PR updated since 2026-10-31: no report or fix of a live reset to AUTO_DETECT_MERGES_AND_THREADS that stays unresolved. Related context only: #7013 (LUCENE-5951, added the constant; in its review hossman noted the AUTO reset branch was a no-op and mikemccand replied that they would add a test that exposes it and fix it, the origin of testResetToAutoDefault), #10385 (LUCENE-9345, the introducing refactor; issue and review do not mention AUTO), #10377 (LUCENE-9337, a different hang after a live maxMergeCount change, a thread-accounting race fixed in 8.6.0), open PR #16889 (thread-count overshoot #13593; edits the same merge()/maybeStall code but does not resolve AUTO), a #14964 review remark (mikemccand, 2025-08-06) that a subclass should guard against AUTO 'in case it didn't get default'd yet' (no live reset, no hang) and a #15015 review remark that the shared scheduler inside MultiIndexMergeScheduler needed explicit limits (different class and trigger, no cause named).
- Lucene commit tested
- main
5adff78bb168(2026-11-02) through gradle in a scratch worktree with assertions on and off, without and with fix A (the repro,TestConcurrentMergeScheduler,TestIndexWriterMerging,TestIndexWriterForceMerge,TestMergeSchedulerExternal,checkGoogleJavaFormat), and standalone on the main jars (the repro with and without the fix, a stack probe of the hangingforceMerge). On the releases the hunt ran its commit-path variants (a version-neutralmain()and JUnit tests that reset to AUTO and then commit): 7.7.3 and 8.5.2 pass, 8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and 10.6.0 hang. The fact check ran the minimal repro on the 10.6.0 jars (fail) and amain()with itsforceMergesteps on 8.5.2 (pass) and 8.6.0 (hang). Only Linux aarch64 with OpenJDK 25.0.3 was run.
- Finding record
lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:61-66 (at 5adff78bb168) (AUTO_DETECT_MERGES_AND_THREADS javadoc: 'Dynamic default for maxThreadCount and maxMergeCount, based on CPU core count'; value -1)lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:122-158 (at 5adff78bb168) (Expert setMaxMergesAndThreads: the AUTO/AUTO pair is accepted with the comment '// OK' and stored as -1/-1 at :133-137; mixed pairs and values below 1 are rejected)lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:442-453 (at 5adff78bb168) (initDynamicDefaults, the only code that resolves AUTO), :533-539 (initialize, its only caller)lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:541-566 (at 5adff78bb168) (merge calls maybeStall before getNextMerge: fix site), :609-643 (maybeStall loop 'while (mergeSource.hasPendingMerges() && mergeThreadCount() >= maxMergeCount)' at :611), :647-654 (doStall: wait(250))lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:960 (at 5adff78bb168) (mergeScheduler.initialize, called once from the constructor)lucene/core/src/test/org/apache/lucene/index/TestConcurrentMergeScheduler.java:666 (at 5adff78bb168) (testLiveMaxMergeCount changes the limits on a live writer's scheduler), :873-896 (testResetToAutoDefault: stored values only, on a scheduler no writer uses)lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:1946-1960 (at 5adff78bb168) (maybeChangeLiveIndexWriterConfig randomly calls setMaxMergesAndThreads on live writers)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:61-66 (at 5adff78bb168) (AUTO_DETECT_MERGES_AND_THREADS javadoc), :122-158 (setMaxMergesAndThreads), :166-193 (setDefaultMaxMergesAndThreads), :442-453 (initDynamicDefaults), :533-539 (initialize), :542-597 (merge), :609-643 (maybeStall), :647-654 (doStall)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:960 (at 5adff78bb168) (mergeScheduler.initialize in the constructor)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1374-1392 (at 5adff78bb168) (shouldClose), :2576-2599 (rollback, rollbackInternal), :3773-3775, :3892 (prepareCommitInternal holds commitLock around the merge-on-commit call)lucene/core/src/test/org/apache/lucene/index/TestConcurrentMergeScheduler.java:471 (at 5adff78bb168) (a unit test calls initialize() directly)
- Who is affected
lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:630-634 (at 5adff78bb168) (the verbose stall message)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:61-66, (at 5adff78bb168) :122-158 (javadoc and setter)lucene/core/src/test/org/apache/lucene/index/TestConcurrentMergeScheduler.java:666 (at 5adff78bb168) (testLiveMaxMergeCount), :995 (testChangeMaxMergeCountyWhileForceMerge), :873-896 (testResetToAutoDefault)lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:1886, (at 5adff78bb168) :1946-1960 (maybeChangeLiveIndexWriterConfig)
- Suggested fix
lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:201, (at 5adff78bb168) :217, :227, :318-439, :442-453, :840, :971 (other readers of the limits and the setters that call updateMergeThreads; initDynamicDefaults ignores its argument)
- Diagrams
lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:132-158, (at 5adff78bb168) :442-453, :533-539, :542-597, :609-654
- TestCMSLiveResetToAutoDetect.java (lucene/core, package org.apache.lucene.index, 1 test):
repro/LFV-1153/TestCMSLiveResetToAutoDetect.java(58 lines)
The diagram illustrates the repro's steps on main 5adff78bb168 (ConcurrentMergeScheduler.java:132-158, :533-539, :566, :611, :647-654).
assets/LFV-1153/bug.png(556 KB)
