All findings · Lucene finding · LFV-1153 · cms-live-reset-to-auto-detect-stalls-merges-forever

Resetting an open writer's ConcurrentMergeScheduler to AUTO_DETECT makes the next merge stall forever

On an IndexWriter that is already open, setMaxMergesAndThreads(AUTO_DETECT_MERGES_AND_THREADS, AUTO_DETECT_MERGES_AND_THREADS) leaves both merge limits at -1, so the next forceMerge, commit or close that needs a merge waits forever, with no error and no merge thread. A regression since 8.6.0, still in 10.6.0 and main.

Summary

Priority
Low, rank 791Resetting 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
hang
Since
8.6.0 (2020, LUCENE-9345); 8.5.2 not affectedintroduced 2020-04-24 · about 6 years
Affected
every release run from 8.6.0 to 10.6.0 (6 run) and main; not fixed upstream
Status
documented
Found by
non-formal: code reading of ConcurrentMergeScheduler, witnessed by an executable repro

AI-assisted page: this finding was first signalled by code reading or testing 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

Background: how the merge scheduler limits merges

  1. ConcurrentMergeScheduler, the default scheduler, runs each merge in its own background thread. Two limits control it: maxThreadCount (merges that run at once) and maxMergeCount (merges allowed before the thread asking for one more has to wait).
  2. When maxMergeCount merge threads already exist and another merge is pending, the asking thread (an indexing, commit or forceMerge thread) stalls in maybeStall, napping 250 ms at a time until a merge thread finishes.
  3. AUTO_DETECT_MERGES_AND_THREADS (-1) means "pick from the CPU count". The scheduler turns it into numbers in initialize(), which IndexWriter calls once, when it opens.
Diagram of the bug: an IndexWriter opens and its ConcurrentMergeScheduler resolves AUTO to CPU-based limits; the application then resets the limits to AUTO_DETECT_MERGES_AND_THREADS and both become -1. forceMerge(1) registers a merge of two segments and calls merge(), which first calls maybeStall: the check 'running merge threads (0) >= maxMergeCount (-1)' is always true, so the thread loops in wait(250) and no merge thread is ever started.
After the live reset both limits are -1. Before starting the pending merge, the scheduler checks whether 0 running merge threads >= -1; that is always true, so the thread naps 250 ms and checks again, forever, and the merge never starts. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You open an IndexWriter with a ConcurrentMergeScheduler (the default). On open, its "auto" limits (-1) become CPU-based numbers: on an 8-core machine, 4 merge threads and 9 merges.
  2. 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.
  3. 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.
  4. 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.
  5. commit() and close() 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.

Who is affected

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)

  1. The setter stores -1/-1. setMaxMergesAndThreads (:132-158) takes the AUTO/AUTO pair with the comment // OK and 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.
  2. Only initialize() resolves AUTO by itself. initDynamicDefaults (:442-453) calls setDefaultMaxMergesAndThreads(false) (maxThreadCount = max(1, cores/2), maxMergeCount = maxThreadCount + 5, :190-191) when maxThreadCount is AUTO. Its only caller is initialize() (:533-539), which in production code only IndexWriter calls, once, from its constructor (IndexWriter.java:960; a unit test also calls it directly, TestConcurrentMergeScheduler.java:471). The public setDefaultMaxMergesAndThreads(false) also sets concrete values, but only when the application calls it.
  3. merge() stalls before it starts anything. merge() (:542) loops if (maybeStall(mergeSource) == false) break; (:566) and only then takes the next merge and starts its thread.
  4. The stall check never passes. maybeStall loops while (mergeSource.hasPendingMerges() && mergeThreadCount() >= maxMergeCount) (:611). With maxMergeCount = -1 that holds while any merge is pending, so it calls doStall(), a wait(250) (:647-654), again and again. A merge thread is never stalled (maybeStall returns 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.

Where the caller hangs (executed)

  • forceMerge(1): Object.wait <- doStall <- maybeStall <- merge <- IndexWriter.executeMerge <- maybeMerge <- forceMerge; after 3 s hasPendingMerges() 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-commit maybeMerge (the finder's repro, LogDocMergePolicy), or on the merge-on-commit path in prepareCommitInternal, before the new commit point is written (the independent reproducer: the commit generation stays 1).
  • close(): through IndexWriter.flush (the finder's probe). By reading (not run), an indexing thread whose flush triggers a merge reaches the same call.

Ways out

  • Setting concrete limits again ends the loop (executed: 66 ms).
  • rollback() from another thread freed the stalled forceMerge after 7 ms, which then threw AlreadyClosedException (executed, the fact check's probe). By reading it is not a general way out: on the merge-on-commit path the stalled thread holds commitLock (IndexWriter.java:3775, :3892), which rollbackInternal needs first (:2589), and during a stalled close() a rollback() waits for the close to finish (:2581, :1387).
Exposure and likelihood190 words
  • Who. Code that changes ConcurrentMergeScheduler limits 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 verbose InfoStream line "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).

Minimal repro

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.

TestCMSLiveResetToAutoDetect.java (lucene/core, package org.apache.lucene.index, 1 test)58 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 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();
      }
    }
  }
}
Command
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_DETECT after 10 s, with assertions on and off.

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

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.

Environment

lucenethis 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (only this platform was run)
Command (environment)
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

Observed output

# 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

Why it looks unintended

  • 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_THREADS is the "Dynamic default for maxThreadCount and maxMergeCount, based on CPU core count" (ConcurrentMergeScheduler.java:61-66). The setter takes the pair at any time (// OK, :135). Its @param maxMergeCount says 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. testLiveMaxMergeCount changes the limits through w.getConfig().getMergeScheduler() (TestConcurrentMergeScheduler.java:666), testChangeMaxMergeCountyWhileForceMerge changes them during a forceMerge (:995), and the test framework's maybeChangeLiveIndexWriterConfig calls the setter at random on live writers (LuceneTestCaseParent.java:1946-1960, from RandomIndexWriter), 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.2 merge() began with initDynamicDefaults(writer). LUCENE-9345 (#10385, d7e0b906abc) moved the call into the new initialize() when merge() started to receive a MergeSource, which has no Directory (the diff turns initDynamicDefaults(IndexWriter) into initDynamicDefaults(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.

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

Resolve AUTO at the start of merge() again (fix A) (validated by the checks) (recommended)

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, seed DEADBEEF: 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) and TestMergeSchedulerExternal (2): 42 tests, 0 failures. :lucene:core:checkGoogleJavaFormat passes.

Resolve AUTO in the setter once the scheduler is initialized (not built)

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.

Reject the AUTO pair after initialize() (not built)

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 note

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

How it was found

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 from initialize(), which IndexWriter calls once (the call moved there from merge() in 8.6.0); the setter accepts AUTO at any time, and maybeStall'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 forceMerge repro shown here ("Prior reports, history and checks").

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
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-checkedthree 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-31no report; #7013, #10385, #10377, #9029 and #10616 are the nearest items
Open PRs2026-11-02the 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, web2026-11-02dev@, java-user@, issues@ and commits@; direct JIRA queries over LUCENE and SOLR; Elasticsearch, OpenSearch and Solr repositories; web queriesnothing about this bug

History

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

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle: the repro fails with assertions on and off; passes with fix A
10.5.2, 10.6.0 JUnitthe 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() formthe 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() formthe 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)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedOwn 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 realOwn 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, LowRe-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 confirmedThe one-test forceMerge repro shown here fails on main with assertions on and off and passes with the fix.
this doc reproduced; fix A validatedGradle: 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 fixedThe 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.

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

Finding record

LFV-1153, snapshot of 2026-11-06

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 hanging forceMerge). On the releases the hunt ran its commit-path variants (a version-neutral main() 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 a main() with its forceMerge steps on 8.5.2 (pass) and 8.6.0 (hang). Only Linux aarch64 with OpenJDK 25.0.3 was run.

Lucene and upstream sources

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

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

The diagram illustrates the repro's steps on main 5adff78bb168 (ConcurrentMergeScheduler.java:132-158, :533-539, :566, :611, :647-654).

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.