All findings · Lucene finding · LFV-586 · addindexes-codecreader-hangs-with-nomergescheduler

Since 9.4.0, addIndexes(CodecReader...) blocks indefinitely when the writer uses NoMergeScheduler

Since 9.4.0, IndexWriter.addIndexes(CodecReader...) hands its work to the merge scheduler as a merge and then waits for it with no time limit. NoMergeScheduler never runs merges, so the call blocks, even with NoMergePolicy as its javadoc recommends, until another thread interrupts it or aborts the writer's merges (e.g. rollback()).

Summary

Priority
Low, rank 797Since 9.4, addIndexes(CodecReader...) under NoMergeScheduler blocks on every call, but nothing is damaged and the pairing is unusual, so it ends the new hang rows before LFV-208.
Impact
hang
Since
9.4.0 (LUCENE-10216, 2022)introduced 2022-07-06 · about 4 years
Affected
every release 9.4.0 to 10.6.0, and main
Status
documented
Found by
semi-formal: a TLA+ liveness assumption checked against the shipped merge schedulers

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

The finding in 30 seconds

Since 9.4.0, addIndexes(CodecReader...) is carried out as a merge that the merge scheduler runs while the caller waits; NoMergeScheduler runs nothing.

Merge policy, merge scheduler and addIndexes

  1. An IndexWriter has a merge policy, which decides which merges to do, and a merge scheduler, which runs them (by default ConcurrentMergeScheduler, on background threads).
  2. NoMergePolicy selects no merges; NoMergeScheduler runs none. The NoMergeScheduler javadoc recommends using both to disable merging.
  3. addIndexes(CodecReader...) copies the segments of readers you pass into the writer. Since 9.4.0 that copy is itself a merge, chosen by MergePolicy.findMerges(CodecReader...) (one merge of all readers by default) and run by the merge scheduler.
Diagram of the bug: addIndexes(CodecReader...) on a writer with NoMergeScheduler and NoMergePolicy asks the policy for a merge of the reader, registers it, hands it to NoMergeScheduler, whose merge method is empty, and then waits in MergeSpecification.await with no time limit. The merge never runs, so the call is still blocked after 10 seconds until another thread rolls the writer back. In 9.3.0 the same call merged the reader in the calling thread and returned.
addIndexes(CodecReader...) hands its one merge to NoMergeScheduler, which never runs it, and then waits on it with no time limit; up to 9.3.0 the call merged the reader itself and returned. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 1 document, open its segment as a CodecReader, and open a second, empty writer with NoMergeScheduler and NoMergePolicy, the pair the NoMergeScheduler javadoc recommends to disable merging. Call addIndexes(reader).
  2. You expect the document to be added, as 9.3.0 does (the whole test takes 1.3 s there), or at least that the call returns or throws.
  3. Since 9.4.0 the call turns the reader into a merge, hands it to the merge scheduler and waits on it with no time limit. NoMergeScheduler.merge is empty, so nobody ever runs that merge.
  4. The call is still blocked after 10 s, parked in MergeSpecification.await (3 ms of CPU after 4 s). Only another thread can release it, with an exception: an interrupt, or anything that aborts the writer's merges, such as rollback(), close() or deleteAll(). A single-threaded caller never gets control back. The document is not added and no merge output is written.

Who is affected

Applications that set the Expert option NoMergeScheduler (alone, with the default policy or with NoMergePolicy) and call addIndexes(CodecReader...), for example to copy, filter or split segments into a writer with merges disabled. Every such call blocks, at any size (with NoMergePolicy or the default policy). addIndexes(Directory...) and schedulers that run merges are unaffected. Rare: no report found in four years.

Mechanism, terms and numbers464 words

Up to 9.3.0, addIndexes(CodecReader...) merged the readers itself, in the calling thread, and its javadoc said it did not use the MergeScheduler. Since 9.4.0 (LUCENE-10216) it asks the merge policy for a merge of the readers, hands that merge to the merge scheduler and waits for it with an unbounded CompletableFuture.get(). NoMergeScheduler.merge does nothing, so the merge never starts and the call blocks until another thread interrupts it (ThreadInterruptedException) or aborts the writer's merges, for example with rollback(), close() or deleteAll() (MergeAbortedException). NoMergePolicy keeps that merge on purpose so that addIndexes keeps working with merges disabled, so the pair the NoMergeScheduler javadoc recommends hangs too. Every release from 9.4.0 to 10.6.0 and main is affected; nothing is corrupted and the readers are simply not added. The claim is narrow: the call should return or throw, not block.

The code

  • The hand-off and the wait (IndexWriter.java:3306-3313, all from LUCENE-10216):
MergePolicy.MergeSpecification spec = mergePolicy.findMerges(readers);
boolean mergeSuccess = false;
if (spec != null && spec.merges.size() > 0) {
  try {
    spec.merges.forEach(addIndexesMergeSource::registerMerge);
    mergeScheduler.merge(addIndexesMergeSource, MergeTrigger.ADD_INDEXES);
    spec.await();
  • registerMerge only estimates the merge's size and queues it (IndexWriter.java:3408-3417); only a scheduler that calls getNextMerge() and merge(...) on that source runs it.
  • No time limit: MergeSpecification.await() is "Waits, until interrupted, for all merges to complete", a plain CompletableFuture.get() (MergePolicy.java:533-544). A bounded await(long, TimeUnit) exists (:546-557) but is not used here.
  • The scheduler does nothing: public void merge(MergeSource mergeSource, MergeTrigger trigger) {} (NoMergeScheduler.java:45), empty since the class was added in 2010. The MergeScheduler contract is "Run the merges provided by MergeSource#getNextMerge()" (MergeScheduler.java:43-49).
  • The policy still asks for the merge: NoMergePolicy.findMerges(CodecReader...) returns the default single merge, "to enable addIndexes for consumers like IndexRearranger" (NoMergePolicy.java:42-50). The default TieredMergePolicy does not override that method and returns the same single merge.
  • Only an abort or an interrupt releases it: everything that calls abortMerges() does, for example rollback() and close() (through rollbackInternal(), IndexWriter.java:2621) and deleteAll() (:2771; its javadoc warns that concurrent addIndexes(CodecReader[]) callers may receive MergeAbortedException, :2743-2745). abortMerges() aborts the queued addIndexes merges (:2821-2837, :3443-3454) and addIndexes then throws MergeAbortedException("merge was aborted.") (:3376-3378). An interrupt gives ThreadInterruptedException (MergePolicy.java:539-540) but leaves the unstarted merge queued until something aborts merges. The parked thread holds no writer lock: the call's synchronized block ends before the hand-off (IndexWriter.java:3298-3304).
  • Not a race: it waits on a merge that nothing executes. Deterministic, seed-independent, with assertions on and off.

What is not affected

addIndexes(Directory...) with the same configuration returns and adds the documents (3 -> 8 docs in a verifier's control). The merge-running schedulers that were tested work: SerialMergeScheduler and the default ConcurrentMergeScheduler, each with NoMergePolicy.

Exposure and likelihood222 words
  • Who. Code that configures NoMergeScheduler (IndexWriterConfig.setMergeScheduler is documented as "Expert", IndexWriterConfig.java:271-279) and adds readers with addIndexes(CodecReader...). It hangs with NoMergePolicy and with the default TieredMergePolicy.
  • Effect. The first call blocks, deterministically, with any number of documents (the repro adds 1), whenever the merge policy returns a non-empty reader-merge spec: NoMergePolicy and the default TieredMergePolicy both do; a custom policy returning null or an empty spec makes the call return early (IndexWriter.java:3325-3338). The thread parks without CPU use; a single-threaded caller never gets control back. The readers are not added and no merge output is written; nothing is corrupted. The flush at the start of the call (IndexWriter.java:3286) still writes the target's own buffered changes, if it has any.
  • In-tree reach. The benchmark module accepts merge.scheduler=NoMergeScheduler (CreateIndexTask.java:110-113) and AddIndexes(false) calls addIndexes(CodecReader...) (AddIndexesTask.java:72, conf/addIndexes.alg:41): a verifier ran that combination, which hangs on main and returns on 9.3.0. PKIndexSplitter takes caller-supplied configs and calls addIndexes(CodecReader...), so a caller passing NoMergeScheduler would hit it (code reading). IndexRearranger builds its own configs with the default scheduler and is not affected.
  • Not hit. addIndexes(Directory...); the merge-running schedulers that were tested, SerialMergeScheduler and ConcurrentMergeScheduler, each with NoMergePolicy.
  • Likelihood: rare. The pairing is unusual; the recorded searches found no report since 9.4.0 (2022).

Minimal repro

One JUnit class, one method, public API only: index 1 document, open a second writer with NoMergeScheduler and NoMergePolicy, call addIndexes with that segment reader on a watchdog thread, and assert that it finished within 10 s. rollback() then releases the blocked call. It fails with assertions on and off.

TestAddIndexesNoMergeScheduler.java (one test class)57 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.index;

import org.apache.lucene.document.Document;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestAddIndexesNoMergeScheduler extends LuceneTestCase {

  public void testAddIndexesCodecReaderReturns() throws Exception {
    try (Directory src = new ByteBuffersDirectory();
        Directory dst = new ByteBuffersDirectory()) {
      try (IndexWriter sw = new IndexWriter(src, new IndexWriterConfig())) {
        sw.addDocument(new Document());
      }
      try (DirectoryReader r = DirectoryReader.open(src)) {
        CodecReader leaf = (CodecReader) r.leaves().get(0).reader();
        IndexWriter w =
            new IndexWriter(
                dst,
                new IndexWriterConfig()
                    .setMergeScheduler(NoMergeScheduler.INSTANCE)
                    .setMergePolicy(NoMergePolicy.INSTANCE));
        Thread t =
            new Thread(
                () -> {
                  try {
                    w.addIndexes(leaf);
                  } catch (Exception returnedByThrowing) {
                  }
                });
        t.start();
        t.join(10_000);
        boolean blocked = t.isAlive();
        w.rollback(); // releases a blocked call with MergeAbortedException
        t.join();
        assertFalse("addIndexes must return or throw, but was still blocked after 10 s", blocked);
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestAddIndexesNoMergeScheduler -Ptests.seed=DEADBEEF
Expected
addIndexes returns (adding the document) or throws within 10 s; on 9.3.0 it returns at once.
Observed
addIndexes must return or throw, but was still blocked after 10 s; the thread is parked in MergeSpecification.await called from IndexWriter.java:3313.

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

More files, environment, full output323 words

The class sits in org.apache.lucene.index so it can live next to TestAddIndexes upstream; it uses no package-private access. Smallest input: one empty document, one segment, ByteBuffersDirectory; the source writer uses a default IndexWriterConfig, the target writer sets only NoMergeScheduler and NoMergePolicy. The extra thread is only a watchdog, because a hang cannot be bounded on the calling thread. After waiting up to 10 s the test records whether the call is still running, calls rollback(), which releases a blocked call (so no thread leaks), waits for the thread, and fails if the call had been blocked. Any exception from the call counts as returning, so a fail-fast fix passes.

Regression test: this same class (or its one method added to TestAddIndexes); it passes with the recommended fix, well under a second. It accepts any exception, so with the fail-fast fix an expectThrows(IllegalStateException.class, ...) on the calling thread pins the fix more tightly.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; releases 10.6.0 and 9.4.0: fail; 9.3.0: passes; with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; a wait on a future that nothing completes)
Command (environment)
cp $REPRO/TestAddIndexesNoMergeScheduler.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestAddIndexesNoMergeScheduler -Ptests.seed=DEADBEEF
# takes about 10 s to fail; assertions off fails the same way: -Ptests.asserts=false (the recorded runs added --offline)

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF (stack trimmed)
TestAddIndexesNoMergeScheduler > testAddIndexesCodecReaderReturns FAILED
    java.lang.AssertionError: addIndexes must return or throw, but was still blocked after 10 s
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:4385ADD63B8A706A]:0)
        at org.apache.lucene.index.TestAddIndexesNoMergeScheduler.testAddIndexesCodecReaderReturns(TestAddIndexesNoMergeScheduler.java:53)

# thread dump while blocked (main 645d4fe14a20, seed 1234ABCD, same failure)
"Thread-0" #41 [16675] prio=5 os_prio=0 cpu=3.02ms elapsed=4.17s ... waiting on condition
   java.lang.Thread.State: WAITING (parking)
	- parking to wait for  <0x00000000f5d23bb8> (a java.util.concurrent.CompletableFuture$Signaller)
	at java.util.concurrent.CompletableFuture.get(java.base@25.0.3/CompletableFuture.java:2093)
	at org.apache.lucene.index.MergePolicy$MergeSpecification.await(MergePolicy.java:537)
	at org.apache.lucene.index.IndexWriter.addIndexes(IndexWriter.java:3313)

# standalone runner, seed DEADBEEF: main 645d4fe14a20 fails with -ea and -da (about 11.4 s);
# releases 10.6.0 and 9.4.0 fail; 9.3.0 passes (whole run 1.3 s)

Why it looks unintended

  • NoMergePolicy keeps this merge on purpose "to enable addIndexes", and the NoMergeScheduler javadoc recommends using both.
  • No javadoc says the call may block; where Lucene handles a scheduler that will not run a merge explicitly, it throws.
  • 9.3.0 returns and adds the readers; 9.4.0 hangs.
The full argument213 words
  • Kept working on purpose: LUCENE-10216 added the NoMergePolicy.findMerges(CodecReader...) override that keeps the merge "to enable addIndexes for consumers like IndexRearranger" (NoMergePolicy.java:43-46); the PR author wrote on #633 that "There are consumers of NoMergePolicy that expect addIndexes(CodecReaders...) to work". NoMergeScheduler's javadoc: "it is recommended to use both if you want to disable merges from ever happening" (NoMergeScheduler.java:26-30).
  • No blocking in the contract: the addIndexes(CodecReader...) javadoc (IndexWriter.java:3254-3274) describes merging through MergePolicy#findMerges(CodecReader...) and lists only CorruptIndexException, IOException and IllegalArgumentException.
  • Where Lucene handles it explicitly, it throws: NoMergeScheduler.getIntraMergeExecutor throws UnsupportedOperationException("NoMergeScheduler does not support merges") (NoMergeScheduler.java:57-60). The upstream test scheduler that skips addIndexes merges, TestAddIndexes.PartialMergeScheduler, closes each skipped merge (TestAddIndexes.java:759-790), and its test expects addIndexes to throw (:807-808). These are single cases, not a general contract, and not every case is handled: in a sibling report, close(), forceMerge(1) and forceMergeDeletes() wait forever with NoMergeScheduler, known since #5316 (2014) and still unfixed.
  • A regression: up to 9.3.0 the method merged inline and its javadoc said "this method does not call or make use of the MergeScheduler" (9.3.0 IndexWriter.java:3124-3125). LUCENE-10216's issue, PR and reviews never discuss a scheduler that runs no merges.
  • Tests: no upstream test combines NoMergeScheduler with addIndexes.
The devil's-advocate check170 words

The strongest case for "intended" (a read-only cross-model review rated it strong; the second cross-model review and the adjudication, moderate): the configuration contradicts itself. NoMergeScheduler "never executes any merges ... regardless of the MergePolicy used"; since 9.4.0 the CHANGES entry for LUCENE-10216 says the MergeScheduler triggers the merges "required by addIndexes(CodecReader[])", and MergePolicy.findMerges(CodecReader...) calls that merge required (MergePolicy.java:619). A user who disables every merge and asks for a merge-based operation gets no readers added.

Why it fails: that justifies at most not adding the readers, not a silent wait with no time limit that a single-threaded caller cannot leave. Where Lucene handles a scheduler that will not run a merge explicitly, it throws (getIntraMergeExecutor, PartialMergeScheduler), Lucene deliberately kept this method working for the "no merges" policy, and nothing in the javadoc of the recommended pair hints that it breaks this method. Hence the narrow claim: return or throw. Note that the 9.4.0 hand-off itself was announced in CHANGES; only its consequence for NoMergeScheduler was not.

Suggested fix

Recommended Fail fast in addIndexes(CodecReader...) when the scheduler is NoMergeScheduler (validated)

Fail fast: in addIndexes(CodecReader...), throw IllegalStateException when the scheduler is NoMergeScheduler, before any merge is registered (+7 lines in IndexWriter.java). The call then throws at once and adds nothing. It passes the repro, TestAddIndexes, TestNoMergeScheduler and TestNoMergePolicy (51 tests).

if (spec != null && spec.merges.size() > 0) {
  if (mergeScheduler == NoMergeScheduler.INSTANCE) {
    // the readers are added through merges, which NoMergeScheduler never runs: fail fast
    // instead of waiting forever in spec.await()
    throw new IllegalStateException(
        "addIndexes(CodecReader...) adds the readers through merges, but the configured"
            + " NoMergeScheduler never runs merges");
  }
  try {
    spec.merges.forEach(addIndexesMergeSource::registerMerge);
All options and trade-offs4 options · 533 words

One fix was validated through gradle in a scratch worktree of 645d4fe14a20; a second (run the merge inline) passed the hunt's own check; a javadoc note is a complement. A generic "merges still queued" check was considered and rejected.

Fail fast in addIndexes(CodecReader...) when the scheduler is NoMergeScheduler (validated) (recommended)

Check the scheduler right after the merge policy returns a non-empty spec and before registerMerge, so nothing is queued and no merge output is written (the call's initial flush still runs first, as today).

if (spec != null && spec.merges.size() > 0) {
  if (mergeScheduler == NoMergeScheduler.INSTANCE) {
    // the readers are added through merges, which NoMergeScheduler never runs: fail fast
    // instead of waiting forever in spec.await()
    throw new IllegalStateException(
        "addIndexes(CodecReader...) adds the readers through merges, but the configured"
            + " NoMergeScheduler never runs merges");
  }
  try {
    spec.merges.forEach(addIndexesMergeSource::registerMerge);

Trade-offs

  • Only calls that hang today change; a null or empty spec still returns early (IndexWriter.java:3325-3338). NoMergeScheduler is a final singleton with a private constructor, so the identity check is exact.
  • The readers are still not added: users who want "no merges, but addIndexes works" switch to a merge-running scheduler with NoMergePolicy (works today) or to addIndexes(Directory...); the message could say so.
  • A custom scheduler that silently drops merges still hangs; that scheduler breaks the MergeScheduler contract ("Run the merges provided by...").
  • IllegalStateException vs UnsupportedOperationException (as getIntraMergeExecutor throws) is the maintainers' choice.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31): stock, the minimal test fails. With the fix: the minimal test passes (well under a second) with assertions on and off; TestAddIndexes 39/39, TestNoMergeScheduler 3/3 and TestNoMergePolicy 8/8 pass: 51 tests, 0 failures. Formatting was not checked.
  • The standalone runner gave the same pass on 645d4fe14a20 with assertions on and off, and the hunt's check on a9779ef3c193 (the call throws within the bound, nothing added).

Let NoMergeScheduler run ADD_INDEXES merges in the calling thread (alternative) (validated by the checks)

In NoMergeScheduler.merge, when the trigger is MergeTrigger.ADD_INDEXES, loop getNextMerge() / mergeSource.merge(merge); getIntraMergeExecutor returns Runnable::run instead of throwing.

Trade-offs

Restores the observed add-and-return outcome of 9.3.0 (the readers are added, through today's scheduler path rather than 9.3.0's inline merge), matching the intent of the NoMergePolicy override. But it breaks the class's documented promise ("never executes any merges") and needs a javadoc change, and TestNoMergeScheduler calls merge(null, random trigger), which would throw NullPointerException when the random trigger is ADD_INDEXES, so it needs a null guard.

What the checks validated

The hunt's check on a9779ef3c193 passed: the call returned with no exception and added the document. Not run through gradle or the module's tests.

Document that addIndexes(CodecReader...) needs a merge-running scheduler (complement)

A note on addIndexes(CodecReader...) and on NoMergeScheduler.

Trade-offs

Alone it leaves the hang in place; useful together with either fix.

Fail if merges are still queued after mergeScheduler.merge returns (rejected)

Generic, so it would also catch custom schedulers.

Trade-offs

Wrong for legal schedulers: ConcurrentMergeScheduler.merge stops pulling merges when maybeStall returns false (ConcurrentMergeScheduler.java:564-568; subclasses may override maybeStall, :599-609) and picks queued merges up later when a merge thread finishes (:678-686). Not run.

Downstream note

Downstream, the risk is a stuck index-administration thread in a service that pairs NoMergeScheduler with addIndexes(CodecReader...); a read-only, non-exhaustive code search found Elasticsearch pairing it with the unaffected addIndexes(Directory...), found that Solr cannot configure NoMergeScheduler, and found no use in OpenSearch.

How it was found

A TLA+ model of IndexWriter's forceMerge wait protocol proved "no caller waits forever" only under the assumption that some merge thread eventually takes every pending merge. Checking that assumption against Lucene's shipped schedulers found NoMergeScheduler, which takes none; following it into addIndexes(CodecReader...) gave this bug, confirmed by a JUnit on the stock jar and a release bisection.

The method in full161 words

Method: semi-formal (an assumption of a TLA+ liveness proof checked against the code, then a public-API JUnit).

  • Model. ForceMergeWait.tla models callers waiting for forced merges and merge threads running them. Its property F3, "no caller waits forever" (<>(cpc[c] \in {"ok", "threw"}) for every caller), holds under a fairness assumption on merge threads (TLC: 73,524 and 916,793 states for one and two callers). The model does not cover addIndexes.
  • Signal. Checking that fairness assumption against the shipped schedulers: NoMergeScheduler.merge takes no merge at all. Two code paths wait on such merges: close() / forceMerge (a separate, already known report) and, since 9.4.0, addIndexes(CodecReader...) (this bug).
  • Confirmation. A JUnit on the stock main jar blocked with assertions on and off; a release bisection showed 9.3.0 returns and 9.4.0 hangs. An independent reproducer, a devil's-advocate verifier, read-only cross-model reviews (OpenAI GPT-5.6), an adjudication and a credibility audit checked it; a follow-up ran the version history, a prior-report search and a security screen.

Prior reports, history and checks

Prior reports

No prior report found (195 logged queries on 2026-10-26 over GitHub, JIRA, the mailing lists and the web; rechecked 2026-10-31). Closest: 2014 comments on #5316 about close() waiting forever with NoMergeScheduler, a different code path whose workaround (NoMergePolicy) still hangs here.

Searches3 searches

Verdict new: no issue, PR, JIRA entry, list thread or downstream issue reports addIndexes(CodecReader...) blocking with NoMergeScheduler, or discusses the two together; no open PR fixes it. The #5316 comments (LUCENE-4246, 2014) describe the sibling problem in close(), eight years before this code path existed.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene and downstream search engines), JIRA, mailing lists, web2026-10-26a scripted prior-report search (110 queries), 76 GitHub GraphQL searches, full reads of LUCENE-10216 (#11253, #633 with all 52 inline review comments and 39 reviews, #1051) and 16 other items, a JIRA search for NoMergeScheduler across all ASF projects (7 issues), mailing-list threads, the touched files of all 406 open apache/lucene PRsno report; closest #5316 (theme only); related #13124 / #13190 (getIntraMergeExecutor throws), #14993, #14994, #15120 (other AddIndexesMergeSource defects), SOLR-11581 (Solr cannot configure NoMergeScheduler)
Verifiers' own searches2026-10-26two scripted prior-report search runs (45 and 40 queries)no report
GitHub (recheck)2026-10-31NoMergeScheduler in apache/lucene issues and PRs; issues and PRs mentioning addIndexes created since 2026-10-26the same 4 items from 2010-2014 (#3407, #3452, #3460, #5316); nothing new

History

Introduced in 9.4.0 by LUCENE-10216 (#633 on main, 698f40ad51a, 2022-07-06; backport #1051, 5dd8e9bdc5a), which made addIndexes(CodecReader...) hand its merge to the scheduler and wait. Both commits hang and their parents return; 9.3.0 returns, and every release from 9.4.0 to 10.6.0 and main hangs.

Versions tested6 rows

Introduced by LUCENE-10216 (#11253; commit and PR title "Use MergeScheduler and MergePolicy to run addIndexes(CodecReader[]) merges"; Vigya Sharma): main 698f40ad51a (#633, merged 2022-07-06) and branch_9x backport 5dd8e9bdc5a (#1051, merged 2022-08-01), first released in 9.4.0. It replaced the inline SegmentMerger merge with the scheduler hand-off and spec.await(), removed the javadoc note "this method does not call or make use of the MergeScheduler" and added the NoMergePolicy.findMerges(CodecReader...) override. The 9.4.0 CHANGES entry announces the hand-off; it does not mention NoMergeScheduler, whose merge has been empty since 2010. No later commit changes the hand-off.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; 10.6.0 affectedminimal repro: still blocked after 10 s (main with assertions on and off)
9.4.0 to 10.5.2 (29 releases); main a9779ef3c193 affectedfinder's JUnit: no return within 10 s, released only by an interrupt; the minimal repro also fails on 9.4.0
698f40ad51a (#633), 5dd8e9bdc5a (#1051) commitcore-only probe hangs in MergeSpecification.await
parents d537013e7087, 18f839bbf408 commitreturns, 15 -> 16 docs
9.1.0, 9.2.0, 9.3.0 not affectedreturns and adds the readers (9.3.0 also with the minimal repro)
8.11.4, 9.0.0 not affectedcore-only probe returns

Independent checks

An independent reproducer, a devil's-advocate verifier, an adjudication and a credibility audit all reproduced it on stock builds; a read-only cross-model review (OpenAI GPT-5.6) agreed, and a second returned a procedural "not real" because it could not run code. This doc's gradle run reproduced it and validated the fix.

Checks and verdicts277 words

Every executed check ran on stock builds; the cross-model reviewers could only read code, and separate runs re-ran the repro.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (JUnit, release bisection) candidateJUnit (15 committed docs, 10 s bound, SerialMergeScheduler control) fails on main with assertions on and off, 10.5.2 and 9.4.0; passes on 9.3.0; both fix sketches pass.
cross-model review (OpenAI GPT-5.6, refute lens) not real (procedural, low)Read-only, could not run code; agreed with the root cause and that the inputs are legal; rated the self-contradictory-configuration case strong and found the run-inline fix's NPE. Its points were adopted.
Reproducer (own JUnit) real5 tests: both NoMergeScheduler cases (with NoMergePolicy and with TieredMergePolicy) block on main with assertions on and off, 10.5.2, 9.12.3 and 9.4.0; pass on 9.3.0; controls pass; rollback() releases the call with MergeAbortedException.
Cross-model review (OpenAI GPT-5.6, intended lens) real, mediumRead-only; a separate run re-ran the finder's JUnit on main: fails.
Devil's advocate real, medium-highReproduced through Lucene's benchmark module (CreateIndexTask with NoMergeScheduler, then AddIndexes(false)) on main with assertions on and off; returns on 9.3.0; corrected the "unannounced" wording.
Adjudication confirmed, highRe-ran the independent reproduction's JUnit with a new seed on main, assertions on and off: 2 of 5 fail, controls pass.
Credibility audit keepA new minimal JUnit blocks for more than 20 s with assertions on and off; close() from another thread releases it with MergeAbortedException.
This doc reproduced, fix validatedMinimal repro through gradle on 645d4fe14a20; 10.6.0 and 9.4.0 fail and 9.3.0 passes; the fail-fast fix passes the repro and 51 tests of the closest suites.

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

Finding record

LFV-586, snapshot of 2026-11-06

Registry title
Since 9.4.0, IndexWriter.addIndexes(CodecReader...) blocks indefinitely (until another thread interrupts it or rolls the writer back) when the writer uses NoMergeScheduler, also together with NoMergePolicy as the NoMergeScheduler javadoc recommends: the reader merge is handed to the merge scheduler, which never runs it, and is then awaited with no time limit
Area
index / IndexWriter addIndexes(CodecReader...) (merge-scheduler hand-off, NoMergeScheduler)
Priority
Low, rank 797: Since 9.4, addIndexes(CodecReader...) under NoMergeScheduler blocks on every call, but nothing is damaged and the pairing is unusual, so it ends the new hang rows before LFV-208.
Impact class
hang
Impact note
Hits applications that configure NoMergeScheduler (a non-default Expert setting; alone, with the default TieredMergePolicy, or paired with NoMergePolicy as its javadoc recommends) and call addIndexes(CodecReader...), for example to copy, filter or rearrange segments into a writer whose merges are disabled; every such call blocks, deterministically, with -ea and -da and at any size; the calling thread parks in an unbounded wait without CPU use until another thread interrupts it (ThreadInterruptedException) or calls rollback() (MergeAbortedException), so a single-threaded caller never gets control back; the readers are not added and nothing is written or corrupted; addIndexes(Directory...) and every merge-running scheduler are unaffected (executed controls).
Summary
You configure an IndexWriter with NoMergeScheduler (also with NoMergePolicy, the pairing the NoMergeScheduler javadoc recommends) and call addIndexes(CodecReader...). You expect the call to add the readers, as it did up to 9.3.0, or at least to return or throw. Instead, since 9.4.0 (LUCENE-10216) the call hands its reader merge to the merge scheduler and then waits for it with no time limit; NoMergeScheduler.merge does nothing, so the call blocks until another thread interrupts it or rolls the writer back.
Introduced
698f40ad51af0c42b0a4a8321ab89968e8d0860b (main): apache/lucene#633, LUCENE-10216 'Use MergeScheduler and MergePolicy to run addIndexes(CodecReader[]) merges' (issue #11253; author vigyasharma; merged 2022-07-06), and its branch_9x backport 5dd8e9bdc5ae72fc726a98a64bfce5119c77b558 (#1051, merged 2022-08-01), which shipped in 9.4.0. They replaced the inline SegmentMerger merge with registerMerge + mergeScheduler.merge(addIndexesMergeSource, ADD_INDEXES) + spec.await() and removed the javadoc note 'this method does not call or make use of the MergeScheduler' [exec at commit level: 698f40ad51a and 5dd8e9bdc5a hang, their parents d537013e7087 and 18f839bbf408 return; read: git blame of IndexWriter.java:3306-3315 on a9779ef3c193 and the only commit between the 9.3.0 and 9.4.0 tags touching the involved files]. NoMergeScheduler.merge has been empty since the class was added (c8c5dfa5431, LUCENE-2331, 2010). (2022-07-06 (main); 2022-08-01 (branch_9x backport); first release 9.4.0 (tag dated 2022-09-30))
First affected version
9.4.0 [exec: 9.3.0 returns and adds the readers, 9.4.0 blocks; same at commit level]
Affected versions
every release from 9.4.0 through 10.5.2 (29 releases: 9.4.0 to 9.12.3 and 10.0.0 to 10.5.2) and main a9779ef3c193, -ea and -da [exec, follow-up release sweep]; the previous hunt main ea57360912ed too [read: the 7 commits between the two mains touch none of IndexWriter, MergePolicy, NoMergePolicy, MergeScheduler or NoMergeScheduler]. Not affected [exec]: 9.1.0, 9.2.0 and 9.3.0 (candidate JUnit), 9.0.0 and 8.11.4 (core-only probe); older releases predate the scheduler hand-off [read].
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
semi-formal
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record, verdict new; 195 queries, 25 hits recorded, none the same bug): a scripted prior-report search (110 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web), 76 GitHub GraphQL searches, full reads of LUCENE-10216 (#11253, #633 with all inline review comments and reviews, #1051) and 16 other apache/lucene items, term searches of 5 more (including the 67 comments of #5316) and of 11 downstream items, a JIRA text search for NoMergeScheduler across ASF projects (7 issues), mailing-list threads, code search, and the touched files of all 406 open apache/lucene PRs (none fixes it); the independent reproduction's (45 queries) and the intended-behaviour check's (40 queries) scripted prior-report searches agree. Related, theme only: apache/lucene#5316 (LUCENE-4246), 2014 comments that close() waited forever with NoMergeScheduler (LFV-587); that is a different API and code path, 8 years before this one existed, and its workaround (NoMergePolicy) still hangs here. Not fixed on main.
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails through gradle in a scratch worktree and through a standalone runner with assertions on and off; with the fail-fast fix it passes, as do TestAddIndexes, TestNoMergeScheduler and TestNoMergePolicy (51 tests, 0 failures). Releases 10.6.0 and 9.4.0 fail the minimal test and 9.3.0 passes (standalone runner). The hunt ran on main a9779ef3c193 (the involved files are identical) and swept the releases 8.11.4 to 10.5.2. Upstream main 2f424fd428d (2026-10-30), branch_11x 603af870375 and branch_10_6 7e5aec4370e carry the same code (read from the source).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3275 (at a9779ef3c193) (addIndexes(CodecReader...)), :3306-3313 (findMerges(readers); spec.merges.forEach(addIndexesMergeSource::registerMerge); mergeScheduler.merge(addIndexesMergeSource, MergeTrigger.ADD_INDEXES); spec.await())
  • lucene/core/src/java/org/apache/lucene/index/NoMergeScheduler.java:23-31 (at a9779ef3c193) (javadoc: 'never executes any merges', 'regardless of the MergePolicy used', 'recommended to use both'), :45 (merge(...) is empty), :57-60 (getIntraMergeExecutor throws UnsupportedOperationException 'NoMergeScheduler does not support merges')
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:533-544 (at a9779ef3c193) (MergeSpecification.await: 'Waits, until interrupted', unbounded future.get())
  • lucene/core/src/java/org/apache/lucene/index/NoMergePolicy.java:42-50 (at a9779ef3c193) (findMerges(CodecReader...) keeps the default 'to enable addIndexes for consumers like IndexRearranger', LUCENE-10216)
  • lucene/benchmark/src/java/org/apache/lucene/benchmark/byTask/tasks/CreateIndexTask.java:110-113 (at a9779ef3c193) (merge.scheduler=NoMergeScheduler accepted), AddIndexesTask.java:61-72 (AddIndexes(false) calls addIndexes(CodecReader...)); lucene/benchmark/conf/addIndexes.alg:41
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java:119-124 (at a9779ef3c193) (fresh IndexWriterConfigs, NoMergePolicy, default scheduler); PKIndexSplitter.java:67-68, :117, :129 (caller-supplied IndexWriterConfig, addIndexes(CodecReader...))
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3306-3313 (hand-off and wait), :3408-3417 (registerMerge), :2821-2837 and :3443-3454 (abortMerges, abortPendingMerges), :2621 (rollbackInternal -> abortMerges), :2743-2745 and :2771 (deleteAll javadoc and abortMerges call), :3298-3304 (synchronized block before the hand-off), :3376-3378 (MergeAbortedException)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:533-557 (await, unbounded and bounded), :615-631 (findMerges(CodecReader...); TieredMergePolicy does not override it)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/NoMergeScheduler.java:23-31 (javadoc), :45 (empty merge); NoMergePolicy.java:42-50; MergeScheduler.java:43-49
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:271-279
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/byTask/tasks/CreateIndexTask.java:110-113, AddIndexesTask.java:72; lucene/benchmark/conf/addIndexes.alg:41
  • a Lucene main checkout (645d4fe14a20) lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java:119-123; PKIndexSplitter.java:67-68, :117, :129
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3286 (flush before the hand-off), :3325-3338 (null or empty spec returns early)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/NoMergePolicy.java:42-50; NoMergeScheduler.java:23-31, :57-60; MergePolicy.java:615-631; IndexWriter.java:3254-3274
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestAddIndexes.java:759-790, :807-808
  • a Lucene clone releases/lucene/9.3.0:lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3124-3125; releases/lucene/10.6.0:lucene/CHANGES.txt:3553-3554 (9.4.0 section)
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestNoMergeScheduler.java:28-32
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:564-568, :599-609, :678-686; IndexWriter.java:3325-3338; NoMergeScheduler.java:32-39; MergeScheduler.java:43-49
History
  • a Lucene clone origin/main 2f424fd428d, origin/branch_11x 603af870375, origin/branch_10_6 7e5aec4370e (same hand-off and empty NoMergeScheduler.merge) [read]

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the minimal repro's output.

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.