All findings · Lucene finding · LFV-207 · forcemergedeletes-observer-unregistered-merges

forceMergeDeletes returns a MergeObserver that waits on merges IndexWriter refused, so it never completes

With LogDocMergePolicy or LogByteSizeMergePolicy, IndexWriter.forceMergeDeletes() called while another merge holds the same segment returns a MergeObserver over a merge that IndexWriter refused and will never run: it reports 0 of 1 merges done forever, and await() blocks forever. The default TieredMergePolicy is not affected.

Summary

Priority
Low, rank 805The forceMergeDeletes MergeObserver also tracks merges IndexWriter refused, so await() hangs forever, only for LogMergePolicy or custom-policy users of the experimental observer that the big downstreams ignore.
Impact
hang
Since
10.4.0 (#15378, 2025-11-02)introduced 2025-11-02 · about 1 year
Affected
10.4.0 through 10.6.0 and main; earlier releases lack the API
Status
documented
Found by
non-formal: a seeded stress test, then code reading

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

forceMergeDeletes asks the merge policy which merges to run, then registers each one. Registration refuses a merge whose segment is already being merged and returns false, but the result is ignored: the refused merge still goes into the returned observer. It never runs, so it never completes, and the observer waits for it forever.

Background

  1. Proposals and registration. IndexWriter asks its merge policy which segments to merge. A proposed merge is only a proposal: IndexWriter registers it and refuses it if one of its segments is already part of a running merge.
  2. forceMergeDeletes. Merges away deleted documents. Since 10.4.0 it returns a MergeObserver (experimental) that counts finished merges (numMerges(), numCompletedMerges()) and waits for them (await(), await(timeout), awaitAsync()).
  3. Policies differ. The default TieredMergePolicy never proposes a segment that is already being merged. LogDocMergePolicy and LogByteSizeMergePolicy do, for forced-deletes merges.
Diagram of the bug: a background merge holds segment _0; forceMergeDeletes() proposes a second merge of _0, registerMerge refuses it ("reject merge") and the result is ignored; the returned MergeObserver includes the refused merge, which never runs, so it reports 0 of 1 merges complete and await() never returns.
A running merge holds the segment; forceMergeDeletes() proposes the same segment, IndexWriter refuses that merge, and the returned observer waits for it forever: 0 of 1 complete. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Use LogDocMergePolicy, add 2 documents and delete one: one segment, 2 documents, 1 deleted. Start forceMerge(1, false): a background merge of that segment is registered (the test keeps it from finishing).
  2. Call the blocking forceMergeDeletes(). The policy proposes the same segment again. IndexWriter refuses that merge because the segment is already being merged, but still puts it into the observer it returns.
  3. You expect what the javadoc says: "merges will already be complete when it returns", so numCompletedMerges() equals numMerges().
  4. Instead it returns at once with 0 of 1 merges complete, and the count never moves: the refused merge never runs.
  5. forceMergeDeletes(false) does the same: after the other merge finished and the writer closed, await() was still blocked after 5 s, await(5, SECONDS) returned false and awaitAsync() had not completed.

Who is affected

Applications on 10.4.0 or later that use LogDocMergePolicy, LogByteSizeMergePolicy or a custom policy that proposes segments already being merged, call forceMergeDeletes while another merge runs (an ordinary background merge is enough) and wait on the returned observer. The waiting thread hangs; the index and the writer are fine.

Mechanism, terms and numbers759 words

IndexWriter.forceMergeDeletes(boolean) asks the merge policy for forced-deletes merges, calls registerMerge on each and ignores its boolean result. registerMerge returns false, logging only "reject merge ... is already marked for merge", when one of the merge's segments is already part of a registered merge; such a merge is never queued, never run and never closed. The method then returns new MergePolicy.MergeObserver(spec) over the whole, unfiltered proposal. A merge's completion future is completed only when the merge is closed, so the observer never completes: numCompletedMerges() stays below numMerges(), await() blocks until interrupted (even after IndexWriter.close()), awaitAsync() never completes, and await(timeout) returns false, which its javadoc reads as a timeout or an error although nothing failed. The blocking forceMergeDeletes() does not hang itself (its wait loop keeps waiting only while a proposed merge is pending or running) but returns without waiting for the refused merge, with an incomplete observer, against its javadoc "merges will already be complete when it returns". It needs LogDocMergePolicy, LogByteSizeMergePolicy or a custom policy that proposes segments already being merged, plus any overlapping merge (an ordinary background merge is enough); the default TieredMergePolicy skips such segments. The observer is @lucene.experimental and new in 10.4.0 (#15378); every release since has the bug. The index is not damaged. Observing only the merges that registered fixes it.

The code (main 645d4fe14a20)

  1. Registration ignores the answer. Under the writer lock, IndexWriter.java:2358-2365: spec = mergePolicy.findForcedDeletesMerges(segmentInfos, cachingMergeContext); (:2359), then for (int i = 0; i < numMerges; i++) registerMerge(spec.merges.get(i)); (:2363).
  2. registerMerge can refuse. Its javadoc: "Else (the merge conflicts) false is returned" (IndexWriter.java:5153-5157). For a segment in mergingSegments it logs "reject merge ... is already marked for merge" and returns false (:5171-5183), before pendingMerges.add(merge) (:5206). The other false branch, "does not exist in live infos" (:5184-5195), cannot fire for a policy that proposes segments of the infos it was given, because the proposal is computed under the same lock; a custom policy that proposed other segments would end up the same way.
  3. Nobody completes a refused merge. OneMerge.mergeCompleted (MergePolicy.java:192) is completed only in OneMerge.close (:285-299, the one complete call at :289). A merge that never entered pendingMerges is never picked up by a merge thread, so nothing closes it.
  4. The observer wraps the unfiltered proposal. return new MergePolicy.MergeObserver(spec); (IndexWriter.java:2405). numMerges() is the proposal's size (MergePolicy.java:885-887), numCompletedMerges() counts completed futures (:895-906), await() is CompletableFuture.allOf(...).get(), "Waits, until interrupted" (:528-544, :915-917; an interrupt ends it with ThreadInterruptedException), await(timeout) returns false on timeout (:547-557, :928-930), and awaitAsync() returns the allOf future (:938-942).
  5. The blocking call does not wait for it. The wait loop (IndexWriter.java:2369-2400) throws on a tragic error or a merge exception and keeps waiting only while a proposed merge is in pendingMerges or runningMerges (:2386-2389). The refused merge is in neither, so the call never waits for it: in the repro, where it is the only proposed merge, the call returns at once; when other proposed merges register, it waits for those and still returns the refused one in the observer.
  6. Which policies trigger it. LogMergePolicy.findForcedDeletesMerges (LogMergePolicy.java:409-465) groups adjacent segments with deletes and never consults getMergingSegments() (its findMerges does, :497). TieredMergePolicy.findForcedDeletesMerges skips merging segments (TieredMergePolicy.java:938, :947, :960). The abstract findForcedDeletesMerges javadoc (MergePolicy.java:654-662) sets no precondition about merging segments.
  7. Why it was harmless before 10.4.0. The loop that ignores the result dates from 2008 (LUCENE-325). While forceMergeDeletes returned void, nothing held a reference to the refused merge.

The trigger without test hooks

Stock LogDocMergePolicy with mergeFactor 3 and three 10-document segments, two with one deleted document each: the third commit starts an ordinary natural merge of all three. forceMergeDeletes(false) then proposes the two segments with deletes; an InfoStream sees IndexWriter log "reject merge", and after waitForMerges() and close() the observer still reports 0 of 1. The latch the tests put in ConcurrentMergeScheduler#doMerge holds the natural merge open so the timing is deterministic; the code path only needs that merge to be still running when forceMergeDeletes is called (every recorded run of this variant used the latch).

  merge thread                                   caller
  merge M1 over segment _0 registered, running
                                                 forceMergeDeletes()
                                                   policy proposes M2 over _0
                                                   registerMerge(M2) -> false  "reject merge" (ignored)
                                                   wait loop: M2 not pending, not running -> return
                                                   return new MergeObserver([M2])
  M1 finishes
                                                 M2: never pending, never run, never closed
                                                 numCompletedMerges() = 0 of 1, forever
Exposure and likelihood277 words
  • Who is hit. Applications that (1) use LogDocMergePolicy, LogByteSizeMergePolicy or a custom policy whose findForcedDeletesMerges proposes a segment that is already being merged, (2) call forceMergeDeletes while an overlapping merge runs (a natural merge, a background forceMerge, or an earlier forceMergeDeletes(false)), and (3) then wait on or poll the returned observer. The default TieredMergePolicy and TemporalMergePolicy skip such segments; TieredMergePolicy controls pass.
  • What breaks. await() without a timeout parks the caller until it is interrupted, also after the writer is closed; awaitAsync() callbacks never run; await(timeout) always returns false, which reads as a timeout or an error; progress reporting with numCompletedMerges() / numMerges(), the javadoc's own example, never reaches 1; the blocking call returns an observer that contradicts its javadoc.
  • What does not break. An executed probe parked an unbounded await() and, from another thread, added and deleted documents, opened an NRT reader, committed, ran forceMergeDeletes again and closed the writer, all normally. The parked thread holds no writer lock and pins no reader or file; deleted documents stayed hidden and CheckIndex was clean. Thread.interrupt() releases it.
  • Scale. None needed: one segment of 2 documents is enough. The window is as long as any overlapping merge runs.
  • Reach. The API is @lucene.experimental and new in 10.4.0. Elasticsearch, OpenSearch and Solr ignore the returned observer (read-only code search, default branches); no downstream report was found. Likelihood: rare.
  • Not part of this bug. The blocking call returning while the competing merge still runs is older behaviour (seen on 9.12.3 and 10.3.2 too); deletes applied after the competing merge opened its readers then stay in the index, hidden, until a later forceMergeDeletes, with TieredMergePolicy too.

Minimal repro

One JUnit test, one method, public API plus the protected ConcurrentMergeScheduler#doMerge hook. A latch in that hook keeps the background forceMerge from finishing, so a merge is reliably in flight when forceMergeDeletes() runs; the latch is released right after the call, so close() works.

TestForceMergeDeletesRejectedMerge.java (one test)66 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.io.IOException;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.ThreadInterruptedException;

public class TestForceMergeDeletesRejectedMerge extends LuceneTestCase {

  public void testObserverCompleteWhenBlockingCallReturns() throws Exception {
    CountDownLatch release = new CountDownLatch(1);
    ConcurrentMergeScheduler heldMerges =
        new ConcurrentMergeScheduler() {
          @Override
          protected void doMerge(MergeSource mergeSource, MergePolicy.OneMerge merge)
              throws IOException {
            try {
              release.await();
            } catch (InterruptedException e) {
              throw new ThreadInterruptedException(e);
            }
            super.doMerge(mergeSource, merge);
          }
        };
    IndexWriterConfig iwc =
        new IndexWriterConfig()
            .setMergePolicy(new LogDocMergePolicy())
            .setMergeScheduler(heldMerges);
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      w.addDocument(List.of(new StringField("id", "a", Field.Store.NO)));
      w.addDocument(List.of(new StringField("id", "b", Field.Store.NO)));
      w.deleteDocuments(new Term("id", "a"));
      w.forceMerge(1, false);
      MergePolicy.MergeObserver observer;
      try {
        observer = w.forceMergeDeletes();
      } finally {
        release.countDown();
      }
      // expected: all observed merges complete on return (javadoc); actual: the rejected one is not
      assertEquals("completed merges", observer.numMerges(), observer.numCompletedMerges());
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestForceMergeDeletesRejectedMerge -Ptests.seed=DEADBEEF
Expected
forceMergeDeletes() returns with numCompletedMerges() equal to numMerges(), as its javadoc promises.
Observed
AssertionError: completed merges expected:<1> but was:<0>, with assertions on and off, on main, 10.6.0 and 10.4.0; the refused merge never completes.

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

More files, environment, full output271 words

Stock LogDocMergePolicy, ByteBuffersDirectory, two documents with one deleted, no sleeps, no reflection; the codec is not pinned (no codec code is involved). Concurrency is a precondition, not noise: a merge must be in flight on the segment, so one merge thread is held by one latch.

The test covers the blocking call. The non-blocking call has the same cause: with forceMergeDeletes(false), an independent test saw numMerges=1 numCompletedMerges=0 await(5s)=false awaitAsync().isDone()=false after IndexWriter.close(), and an unbounded await() had not returned after 5 s.

Regression test: this same test. It passes with the fix and can go into TestIndexWriterMerging next to testForceMergeDeletesBlockingWithObserver. With the fix the assertion holds as 0 == 0 (the refused merge is no longer observed), so on its own it does not show the conflict happened; if the maintainers want that, the test can also check for IndexWriter's "reject merge" InfoStream line, as the natural-merge variant does.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. 10.6.0 and 10.4.0 release jars: fails. 10.3.2 and earlier: the API does not exist
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
cp $REPRO/TestForceMergeDeletesRejectedMerge.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestForceMergeDeletesRejectedMerge -Ptests.seed=DEADBEEF
# the same result with -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestForceMergeDeletesRejectedMerge > testObserverCompleteWhenBlockingCallReturns FAILED
    java.lang.AssertionError: completed merges expected:<1> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:FC4A538E24B1C2C4]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:647)
        at org.apache.lucene.index.TestForceMergeDeletesRejectedMerge.testObserverCompleteWhenBlockingCallReturns(TestForceMergeDeletesRejectedMerge.java:63)

# the same failure from the 10.6.0 and 10.4.0 release jars
# with the fix: 1 test, 0 failures (assertions on and off)

Why it looks unintended

  • The javadoc of forceMergeDeletes() says "merges will already be complete when it returns"; it returns with 0 of 1 complete.
  • await() returns true when the merges complete, false on error; blocking forever is neither.
  • IndexWriter itself treats a refused merge as normal: it logs "reject merge", and its own wait loop skips it.
The full argument182 words
  • The contract. forceMergeDeletes() @return (IndexWriter.java:2421-2422): "a MergeObserver to monitor merge progress. Since this method blocks until completion, merges will already be complete when it returns." forceMergeDeletes(boolean) @return (:2343): "a MergeObserver to monitor merge progress and wait for completion". MergeObserver.await() @return (MergePolicy.java:912-913): "true if all merges completed successfully or no merges were needed, false on error". numMerges() (:883): "number of merges, or 0 if no merges were scheduled".
  • The tests. TestIndexWriterMerging.testForceMergeDeletesBlockingWithObserver uses newLogMergePolicy() and asserts "await should return true immediately" after forceMergeDeletes(true), on an idle writer. No upstream observer test calls forceMergeDeletes while another merge holds the same segments.
  • History. #15378 (by Salvatore Campagna, merged by Vigya Sharma) added the observer for #14515. Its 23 review comments discuss the API's shape, numCompletedMerges, toString and test timeouts, and never mention refused merges. In that review Vigya Sharma asked for "an assert here that tests that numMerges == completedMerges" in the blocking-call test, and Salvatore Campagna replied "Since forceMergeDeletes(true) blocks until completion, all merges should be done when it returns": the assertion this bug breaks.
The devil's-advocate check150 words

The strongest case: IndexWriter.getMergingSegments() is "to be used by a MergePolicy to avoid selecting merges for segments already being merged" (IndexWriter.java:2520-2522), a MergeSpecification is only a proposal, and MergeObserver is @lucene.experimental; so a policy that proposes merging segments misuses the API, and the observer may track proposals.

Why it fails: findForcedDeletesMerges states no such precondition (MergePolicy.java:654-662), while findFullFlushMerges states one explicitly and says what happens (:677-681); the policy that does it is Lucene's own public LogMergePolicy, which the upstream observer tests use; @lucene.experimental allows the API to change, not to contradict what it documents today; and a monitoring handle that can never complete for a legal configuration has no use. Every verifier and the refute review rejected the case; three of them call it weak. At most it moves the fix into LogMergePolicy, which leaves custom policies exposed. Verdict: not intended.

Suggested fix

Recommended Observe only the merges that registered

Keep only the merges that registerMerge accepted in the observed proposal (+8/-1 in IndexWriter.forceMergeDeletes). The repro then passes, and so do 230 tests in 9 closest suites. Trade-off: when every proposed merge is refused, numMerges() is 0, which the javadoc reads as "no merges needed"; maintainers may prefer another option.

      if (newMergesFound) {
        // only merges that were actually registered will run and complete: observe only those
        final MergePolicy.MergeSpecification registered = new MergePolicy.MergeSpecification();
        final int numMerges = spec.merges.size();
        for (int i = 0; i < numMerges; i++) {
          if (registerMerge(spec.merges.get(i))) {
            registered.add(spec.merges.get(i));
          }
        }
        spec = registered;
      }
All options and trade-offs4 options ยท 536 words

One fix was built and validated in a scratch worktree of 645d4fe14a20. The alternatives come from the adjudication and the devil's-advocate verifier; option C is the separate LogMergePolicy bug.

Observe only the merges that registered (validated by the checks) (recommended)

One file, +8/-1, in IndexWriter.forceMergeDeletes(boolean). Every merge left in the proposal is registered (registerMerge returned true), so a merge thread runs it and closing it completes its future; the blocking wait loop and the observer then look at the same set.

      if (newMergesFound) {
        // only merges that were actually registered will run and complete: observe only those
        final MergePolicy.MergeSpecification registered = new MergePolicy.MergeSpecification();
        final int numMerges = spec.merges.size();
        for (int i = 0; i < numMerges; i++) {
          if (registerMerge(spec.merges.get(i))) {
            registered.add(spec.merges.get(i));
          }
        }
        spec = registered;
      }

Trade-offs

When every proposed merge is refused, the observer has numMerges() 0 and await() returns true at once, which the class javadoc reads as "no merges are needed": the caller cannot tell "nothing to do" from "a conflicting merge already holds these segments". The blocking call still returns while that other merge runs (older behaviour, not this bug).

What the checks validated

  • Stock: the minimal test fails with assertions on and off (gradle, seed DEADBEEF, scratch worktree of 645d4fe14a20).
  • With the fix: the minimal test passes with assertions on and off.
  • Closest suites with the fix (every core test class that calls forceMergeDeletes, plus the merge-policy and scheduler suites): TestIndexWriterMerging (incl. the observer tests), TestIndexWriter, TestIndexWriterReader, TestConsistentFieldNumbers, TestSoftDeletesRetentionMergePolicy, TestTieredMergePolicy, TestLogMergePolicy, TestIndexWriterForceMerge, TestConcurrentMergeScheduler: 9 classes, 230 tests, 0 failures, 5 skipped.
  • :lucene:core:checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain pass.
  • Earlier: every hunt repro passes with the fix (the finder's, the independent reproducer's 5 tests, the cross-model reviewers' tests, the natural-merge and custom-policy variants).
  • Not run: nightly tests, benchmarks.

Complete the refused merges' futures

Complete or close each refused OneMerge, so the observer counts it as done and numMerges() keeps the policy's proposal. The future is private and completed only by OneMerge.close, which also runs the merge's mergeFinished hook and reader clean-up, so closing a merge that never started needs a design decision. Not tested.

Trade-offs

complete(true) reports a merge that never ran as completed successfully. complete(false) records the failure only in OneMerge.hasCompletedSuccessfully(): await() still returns true, because allOf(...).get() returns normally for a normally completed future (the same gap as the separate bug where await() returns true after a failed merge). Only completeExceptionally(...) makes await() return false, which the javadoc reads as an error.

Make LogMergePolicy skip segments that are already merging (the sibling bug)

The one-line LogMergePolicy.findForcedDeletesMerges fix of the sibling bug (LogMergePolicy proposes merging segments for forced deletes). With it, the stock-policy repros can no longer set up the conflict.

Trade-offs

Fixes only the two stock Log policies: a custom policy that proposes every segment with deletes still fails with it and passes only with the IndexWriter fix (executed). Worth doing as its own issue.

Document the case

Say in the forceMergeDeletes and MergeObserver javadoc that merges refused because their segments are already merging are included and never complete.

Trade-offs

Cheapest, but await() without a timeout can still hang for a legal configuration.

Downstream note

Elasticsearch, OpenSearch and Solr ignore the observer that forceMergeDeletes returns (read-only code search of their default branches), so their own force-merge paths do not wait on it.

How it was found

A seeded multi-threaded stress test of concurrent indexing, forced merges and forceMergeDeletes(false), which waited on every returned observer after the writer closed, failed in its first smoke run; code reading then found the ignored registerMerge result. A TLA+ model of the merge scheduler covered only registered merges and could not see it.

The method in full123 words

Method: non-formal (a stress test outside the study's baseline gave the first signal). The hunt unit on the merge-scheduler lifecycle wrote a TLA+ model (CmsSched.tla, property TerminalAllClosed: every registered merge is eventually closed) and a seeded stress harness (concurrent indexing, forced merges and forceMergeDeletes(false), with every returned observer awaited after the writer closed). The harness failed in its first 3-iteration smoke run on stock main ("MergeObserver never completed after the writer closed", seed 1234ABCD). The model has no refused merges, so it could not show this.

The finder then found the root cause by reading the code, wrote a deterministic JUnit, and had an independent reviewer from another model family (OpenAI GPT-5.6) try to refute it before filing the candidate.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26, re-checked 2026-10-31) in GitHub issues, PRs and review comments (full scans over the API's whole life), the JIRA archive, the Lucene mailing lists, or Elasticsearch, OpenSearch and Solr. Closest: #15378, which added the observer, and #14431, an open request for a forceMergeDeletes timeout.

Searches5 searches

No issue, PR, review comment, list thread, JIRA entry, downstream report or web page describes the observer tracking merges that registerMerge refused, await() hanging, or the blocking call returning an incomplete observer. Not fixed upstream, and no open PR changes forceMergeDeletes, MergeObserver or LogMergePolicy.findForcedDeletesMerges.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search (GitHub issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA archive; web)2026-10-26four query sets: the observer symptom, the older mechanism, and downstream only; 105 queries and 718 distinct hits in the two main runs, plus a verifier's 24 list and JIRA queriesonly the items that added the API (#15378, #14515) and related items below
apache/lucene, full REST scans (not ranked search)2026-10-26title and body of all 16,898 issues and PRs; all 4,233 issue comments and 2,988 review comments since 2025-10-28, the API's whole life; the changed files of all 413 open PRsno report; #12237 (open) edits the other refusal branch and would not fix it
Related items (read in full)2026-10-26#15378, #14515, #14548, #14431, #15620, LUCENE-1703, OpenSearch#22250 and othersnone is this bug: #15378/#14515 added the observer, #14548 a superseded attempt, #14431 an open timeout request, #15620 states the getMergingSegments convention, LUCENE-1703 a 2009 remark that expungeDeletes(true) waits only for its own merges, OpenSearch#22250 an analogous race in OpenSearch's own code
apache/lucene, GitHub search (read-only re-check)2026-10-31MergeObserver, numCompletedMerges, "already marked for merge"; new issues and PRs on forceMergeDeletes, registerMerge and findForcedDeletesMerges since 2026-10-20only #15378 and #14515; nothing new
Upstream branches (read)2026-10-31upstream main up to 2f424fd428d, branch_11x, branch_10_6same code; no commit since 645d4fe14a20 touches the files involved

History

Introduced by #15378 (merged 2025-11-02), which made forceMergeDeletes return a MergeObserver over the policy's unfiltered proposal; first released in 10.4.0. Fails on 10.4.0 through 10.6.0 and main; earlier releases lack the API. The ignored registerMerge result itself dates from 2008.

Versions tested6 rows

Introduced by #15378 "Return MergeObserver from IndexWriter.forceMergeDeletes() (#14515)" (by Salvatore Campagna; merged and backported by Vigya Sharma), merged 2025-11-02: c85c69f19082 on main and 6862ef978845 on branch_10x, released in 10.4.0. Commit isolation: the 10.3.2 jars with only that change to IndexWriter.java and MergePolicy.java fail exactly like 10.4.0. The older ingredients: the loop that ignores the result (LUCENE-325, 1d4ab6879680, 2008, first in 2.4.0), and LogMergePolicy.findForcedDeletesMerges not skipping merging segments (same commit).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails with assertions on and off (gradle and standalone runner); passes with the fix
10.6.0 affectedthe minimal test fails on the release jars, assertions on and off
10.4.0, 10.5.0, 10.5.1, 10.5.2 affectedthe hunt's JUnit fails (10.4.0 and 10.5.2 with assertions on and off); the minimal test fails on 10.4.0, assertions on and off
upstream main 2f424fd428d, branch_11x, branch_10_6 readthe same registration loop and observer
10.3.2 + only #15378's change bisectfails exactly like 10.4.0
10.3.2, 9.12.3 and earlier API absentforceMergeDeletes returns void; the tests do not compile

Independent checks

Confirmed by an executed cross-model refute review (OpenAI GPT-5.6), an independent reproducer, an executed works-as-designed review (OpenAI GPT-5.6), a devil's-advocate verifier with a natural-merge variant, and an adjudication. This doc's run reproduced it on main, 10.6.0 and 10.4.0 and validated the fix with gradle.

Checks and verdicts284 words

Every check ran its own code; none relied on the finder's test alone. Controls (an idle writer, TieredMergePolicy) pass everywhere.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (stress harness, then code reading and JUnit) candidateJUnit fails on main (assertions on and off; 20 of 20 over 10 iterations) and on 10.4.0 and 10.5.2 (2 of 2 each); passes with the fix.
Refute review (OpenAI GPT-5.6, executed) real (high)Own test with stock LogByteSizeMergePolicy fails 2 of 3 on main, idle control passes; the fix passes its tests and the 13 TestIndexWriterMerging tests.
Independent reproducer realOwn JUnit fails 3 of 5 on main, 10.5.2 and 10.4.0; idle-writer and TieredMergePolicy controls pass; 30 of 30 bug runs fail, 20 of 20 controls pass; all pass with the fix.
Works-as-designed review (OpenAI GPT-5.6, executed) real (high)Own test fails on main and 10.5.2, TieredMergePolicy control passes; passes with the fix; the intended case rated weak.
Intended-behaviour devil's advocate real (high)Natural-merge variant (no forceMerge, no policy wrapper) fails on main and 10.4.0 and passes with the fix; no intended argument survives.
Adjudication confirmed (high)Re-ran the independent reproduction's suite with new seeds (3 of 5 fail, controls pass) and the finder's repro (2 of 2 fail); checked the root cause in the source.
Follow-up: re-runs, security probe, history real; not security-sensitiveCustom-policy variant fails and still fails with the sibling LogMergePolicy fix; commit isolation; parked-await probe shows no lock, no data effect.
This doc (2026-10-31) real; fix validatedThe minimal test fails on main, 10.6.0 and 10.4.0; gradle in a scratch worktree: fails stock, passes with the fix; closest suites pass.

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

Finding record

LFV-207, snapshot of 2026-11-06

Registry title
IndexWriter.forceMergeDeletes returns a MergeObserver that also tracks merges IndexWriter refused to register, so the observer never completes and await() blocks forever
Area
index / IndexWriter forceMergeDeletes MergeObserver (registerMerge, MergePolicy.MergeObserver)
Priority
Low, rank 805: The forceMergeDeletes MergeObserver also tracks merges IndexWriter refused, so await() hangs forever, only for LogMergePolicy or custom-policy users of the experimental observer that the big downstreams ignore.
Impact class
hang
Impact note
Hits only applications that configure LogDocMergePolicy, LogByteSizeMergePolicy or a custom merge policy whose findForcedDeletesMerges proposes a segment that is already being merged (the default TieredMergePolicy and TemporalMergePolicy skip such segments), call forceMergeDeletes while an overlapping merge runs (an ordinary natural merge, a background forceMerge or an earlier forceMergeDeletes(false)), and then wait on or poll the returned MergeObserver (@lucene.experimental, released since 10.4.0): await() with no timeout and awaitAsync() never finish, even after IndexWriter.close(), await(timeout) returns false, and the blocking forceMergeDeletes() returns at once with an observer whose numCompletedMerges() stays below numMerges(); no scale is needed, the parked thread holds no writer lock and is released by an interrupt, the index is not damaged, and Elasticsearch, OpenSearch and Solr ignore the returned observer (read-only code search, default branches).
Summary
You use LogDocMergePolicy or LogByteSizeMergePolicy (or a custom merge policy), call IndexWriter.forceMergeDeletes(false) while another merge is already running on one of the segments with deletes, and wait on the returned MergeObserver, whose javadoc says it lets you 'monitor merge progress and wait for completion'. IndexWriter refuses to register the conflicting forced-deletes merge (it only logs 'reject merge'), so that merge never runs, but it still puts the merge into the observer. The observer then reports 0 of 1 merges complete forever: await() blocks forever even after the writer is closed, await(timeout) returns false, and the blocking forceMergeDeletes() returns at once with an incomplete observer although its javadoc says 'merges will already be complete when it returns'.
Introduced
c85c69f19082 (main, unreleased 11.0) and 6862ef978845 (branch_10x backport, released in 10.4.0), the same patch: #15378 'Return MergeObserver from IndexWriter.forceMergeDeletes() (#14515)' by salvatorecampagna, merged 2025-11-02 (fixes #14515) [read: git log -S 'new MergePolicy.MergeObserver(spec)' and -S 'class MergeObserver' find only these two commits, git blame IndexWriter.java:2405 gives c85c69f1908] [exec: commit isolation, the 10.3.2 jars with only the #15378 diff of IndexWriter.java and MergePolicy.java shadowed fail exactly like 10.4.0, -ea and -da]; the patch wrapped the policy's unfiltered MergeSpecification in the new public observer; the older ingredients it exposed are the registration loop that ignores registerMerge's boolean result (1d4ab6879680, LUCENE-325, 2008, first released in 2.4.0) and LogMergePolicy.findForcedDeletesMerges not skipping merging segments (LFV-178, same 2008 commit) (2025-11-02 (#15378, merged 2025-11-02T23:36:21Z on main; the branch_10x backport was committed the same day))
First affected version
10.4.0 [exec] (first release with MergePolicy.MergeObserver); 10.3.2 and every earlier release, including the whole 9.x line, do not have the API (forceMergeDeletes returns void; the repro does not compile on 10.3.2 and 9.12.3 [exec])
Affected versions
10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 and main ea57360912ed and 645d4fe14a20: every release that contains MergeObserver. [exec] the finder's JUnit fails 2 of 2 on 10.4.0 (-ea, -da), 10.5.0, 10.5.1, 10.5.2 (-ea, -da) and main (-ea, -da; 20/20 over --iters 10), and also with open PR #16889 applied (heads e304ea8cbd6, 2026-10-26, and 6c281d1cb52, 2026-11-02 on main e1faeba08bd with -ea and -da); the independent reproduction's own JUnit fails 3 of 5 (both controls pass) on main (-ea, -da), 10.5.2 and 10.4.0; the intended-behaviour check's natural-merge variant fails on main (-ea, -da) and 10.4.0; the cross-model reviewers' own tests fail on main, the works-as-designed reviewer's also on 10.5.2; the follow-up's custom-policy variant fails on main (-ea, -da) and 10.5.2. [read] the forceMergeDeletes body, MergeObserver, LogMergePolicy.findForcedDeletesMerges and registerMerge hash identically at the 10.4.0, 10.5.0, 10.5.1 and 10.5.2 tags and on main; git tag --contains put the backport in exactly those four releases before 10.6.0 was tagged; re-run on 2026-10-31, it also lists 10.6.0 (tag 4ae946e5a9b) [read]. Not affected: 10.3.2 and earlier, all 9.x (API absent) [exec: compile error on 10.3.2 and 9.12.3]. Before 10.4.0 the same rejection already made the blocking forceMergeDeletes(true) return while the competing merge still ran [exec: mechanism probe on 9.12.3, 10.3.2, 10.4.0, 10.5.2 and main], but there was no observer to hang and no 'complete when it returns' promise, so no claim is made for those versions. 10.6.0 (released 2026-10-29): the minimal test fails on the release jars (4ae946e5a9b9), -ea and -da, and on main 645d4fe14a20, -ea and -da [exec 2026-10-31]. All runs on aarch64 with JDK 25.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal
Upstream and prior reports
none found (2026-10-26, read-only, follow-up prior-report record verdict new: four scripted prior-report searches over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web (105 queries and 718 distinct hits in the two main runs, 4 downstream-only queries in two more), plus a verifier run of 24 list and JIRA queries; full REST scans of the titles and bodies of all 16,898 apache/lucene issues and PRs, every issue comment (4,233) and review comment (2,988) since 2025-10-28, which covers the whole life of the MergeObserver API; the touched files of all 413 open apache/lucene PRs; JIRA REST searches with comments of 10 issues read; the git history); no issue, PR, review comment, list thread, JIRA entry, downstream report or web page describes the observer tracking merges that registerMerge rejected, await() hanging or await(timeout) returning false with no failure, or the blocking forceMergeDeletes returning an incomplete observer; not fixed upstream: the code is unchanged on main ea57360912ed and 645d4fe14a20, upstream main 2f424fd428d (2026-10-30) has no newer commit on IndexWriter.java, MergePolicy.java or LogMergePolicy.java [read 2026-10-31], and no open PR changes forceMergeDeletes, MergeObserver or LogMergePolicy.findForcedDeletesMerges (#12237 edits the other registerMerge rejection branch, #16847 other LogMergePolicy methods); related, not reports of this bug: #15378 (introduced the observer; its 23 review comments cover API shape, numCompletedMerges, toString and test timeouts), #14515 (the feature request), #14548 (superseded attempt), #14431 (open: a timeout for forceMergeDeletes, which await(timeout) would serve), #15620 (states the convention of checking getMergingSegments), LUCENE-1703 (2009 remark that expungeDeletes(true) waits only for its own merges), OpenSearch#22250 (an analogous race fixed in OpenSearch's own merge handler); gaps: GitHub search is ranked and GitHub's search rate limit ran out several times (affected manual searches were re-run through a scripted prior-report search), code search covers default branches only, PR review comments before 2025-10-28 were reached only through search, private security reports cannot be searched.
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner; with the fix it passes, and 230 tests in 9 closest suites pass with it. Release jars 10.6.0 and 10.4.0 fail, assertions on and off. The hunt (main ea57360912ed, byte-identical in the files involved) ran its tests on 10.4.0, 10.5.0, 10.5.1, 10.5.2 and main (fail) and on 10.3.2 and 9.12.3 (the API is absent; does not compile). Upstream main 2f424fd428d, branch_11x and branch_10_6 have the same code (read, 2026-10-31).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2338-2344 (forceMergeDeletes(boolean) javadoc '@return a MergeObserver to monitor merge progress and wait for completion'), :2357-2364 (registerMerge result ignored), :2369-2400 (blocking wait checks only pendingMerges/runningMerges), :2405 ('return new MergePolicy.MergeObserver(spec)'), :2421-2422 (no-arg javadoc 'merges will already be complete when it returns'), :5153-5195 (registerMerge returns false with a 'reject merge' infoStream line when a segment is already marked for merge or not in the live infos, without closing the merge)
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:284-299 (OneMerge.close is the only place mergeCompleted is completed), :528-544 (MergeSpecification.await: CompletableFuture.allOf(...).get(), 'Waits, until interrupted'), :864-940 (MergeObserver, @lucene.experimental; numMerges '0 if no merges were scheduled'; await '@return true if all merges completed successfully or no merges were needed, false on error'; await(timeout) 'false on timeout or error'; awaitAsync), :654-662 (findForcedDeletesMerges javadoc, no merging-segments precondition), :677-681 (findFullFlushMerges states one)
  • lucene/core/src/java/org/apache/lucene/index/LogMergePolicy.java:409-465 (findForcedDeletesMerges never consults getMergingSegments), :497 (findMerges does); lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:938-963 (its findForcedDeletesMerges skips merging segments)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2343, :2345-2405, :2421-2422, :5153-5206 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:192, :285-299, :528-557, :654-662, :677-681, :864-942
  • lucene/core/src/java/org/apache/lucene/index/LogMergePolicy.java:409-465, :497
  • lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:938-963
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2343, :2421-2422, :2520-2522
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:654-662, :677-681, :868-869, :883, :912-913, :925-926
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterMerging.java:516-560 (testForceMergeDeletesBlockingWithObserver; assertion at :554)
  • GitHub API (read-only, 2026-10-31): apache/lucene PR #15378 review comments discussion_r2480276985 (vigyasharma) and discussion_r2482384526 (salvatorecampagna)
Prior reports
  • a Lucene clone: git log 645d4fe14a20..origin/main on IndexWriter.java, MergePolicy.java, LogMergePolicy.java (empty, 2026-10-31); git show origin/branch_11x and origin/branch_10_6 IndexWriter.java
History
  • a Lucene clone: git show releases/lucene/10.4.0, 10.5.2, 10.6.0 and 10.3.2 IndexWriter.java (registration loop, observer, javadoc)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the executed minimal test.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.