All findings · Lucene finding · LFV-590 · forcemerge-adopted-merge-failure-not-reported

IndexWriter.forceMerge(n) can return normally with more than n segments when a merge it adopted fails

If a background merge that forceMerge(n) waits for but did not register itself fails (say, on a full disk), the call can return normally with the index unmerged. The writer then closes itself and close() returns normally too, so forceMerge(1); close() can report success while uncommitted changes are rolled back.

Summary

Priority
Low, rank 285Needs a merge I/O failure such as a full disk plus a lost race, and the writer then closes, so the next modifying call fails loudly.
Impact
wrong error
Since
2.3.0 (released 2008; LUCENE-845, committed 2007)introduced 2007-09-18 · over 19 years
Affected
every release from 2.3.0 to 10.5.2, 10.6.0 (by reading), and main
Status
documented
Found by
semi-formal: TLA+ model checking of the forced-merge wait protocol

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

forceMerge(n) marks merges that are already pending or running as its own but leaves their generation stamp old, so their failure is dropped; the tragic event that would also stop it is set only after the merge thread has woken it.

Summary card: Lucene IndexWriter.forceMerge(1) can return normally although the merge it waited for failed. A timeline of the merge thread (mergeFinish, then the tragedy and the closed writer) against the waiting forceMerge(1), which returns normally in between, and close(), which returns too. Four steps: the flush of forceMerge starts merge M of _0 + _1 stamped mergeGen 0; resetMergeExceptions sets mergeGen to 1 and adopts M, which stays 0; M fails on a simulated full disk and addMergeException drops it; mergeFinish wakes the call before the tragedy is set. Expected: throws IOException background merge hit exception. Actual: returns normally with 2 segments, close() returns too. Fix sketches: set mergeGen on adoption, or set the tragedy before mergeFinish.
From forceMerge's flush that starts merge M, through M's old stamp and the dropped failure, to the normal return before the tragedy; ends on a summary card. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Use the default ConcurrentMergeScheduler and a merge policy that merges 2 segments (LogDocMergePolicy, merge factor 2). Add document 0 and commit; add document 1 without committing.
  2. Call forceMerge(1). Its first step flushes document 1 into a second segment, and that flush starts an ordinary background merge of the 2 segments. forceMerge adopts that merge as its own and waits for it.
  3. The merge fails when it creates a file: the disk is full. You expect forceMerge(1) to throw, as it does when the failing merge is one it started itself: IOException: background merge hit exception.
  4. Instead it returns normally with 2 segments: in the deterministic test every time, and without test points or InfoStream hooks in 22 to 53 of 500 runs. An instant later the writer closes itself and rolls back document 1.
  5. close() then returns normally too, and the index holds 1 of 2 documents. Nothing is corrupted. With the default TieredMergePolicy it is the same: forceMerge(1) returned with 9 segments.

Who is affected

Applications that call the blocking forceMerge(n) while a natural merge is pending or running, including one its own flush starts. No special configuration: the default ConcurrentMergeScheduler and TieredMergePolicy reproduce it; SerialMergeScheduler is not affected. It needs an I/O failure in that merge, such as a full disk, and a short race: rare.

Mechanism, terms and numbers819 words

IndexWriter.forceMerge(n) adopts every merge that is already pending or running when it starts, including one its own flush has just started, and waits for it. If that merge fails, for example on a full disk, the failure is filed under an older merge generation and dropped, and the writer's other alarm, the tragic event, is raised only after the waiting call has been woken. The call can then return normally with more than n segments. A moment later the writer rolls back to the last commit and closes; the next call that changes or commits the index throws AlreadyClosedException, but close() returns normally, so forceMerge(1); close() can report success while the changes since the last commit are gone. No special configuration is needed; it needs a merge I/O failure and, since 5.3.0, a short race, so it is rare. It has been there since 2.3.0.

Terms

  • Segments and merges. An index is a set of segments; merges combine segments, by default in background threads of the ConcurrentMergeScheduler. Commits, flushes and NRT reopens ask the merge policy for merges and register them as pending; a merge thread then runs them.
  • forceMerge(n). "Forces merge policy to merge segments until there are <= maxNumSegments". The blocking form waits until merging finishes; its wait loop is meant to throw when merging cannot finish (its comment: "so the calling thread will know merging did not complete", :2314-2318).
  • Adopted merge. A merge that is already pending or running when forceMerge starts. forceMerge marks it as part of the forced merge (maxNumSegments = n) and waits for it.
  • mergeGen. A counter. Every merge is stamped with its value when it is registered; forceMerge clears its list of failed merges and increments the counter; a failure is recorded in the list only if the merge's stamp equals the counter.
  • Tragic event. A failure the writer cannot recover from; since 5.3.0 every failed merge is one. The writer records it, rolls back to the last commit and closes.

The bug, step by step (main a9779ef3c193, IndexWriter.java)

  1. forceMerge flushes first (:2248). The flush can register a natural merge, stamped with the current generation, say 0 (merge.mergeGen = mergeGen, :5218).
  2. It then clears its failure list and bumps the generation to 1 (resetMergeExceptions(), :2250, :3039-3042), and marks every pending and running merge as forced (merge.maxNumSegments = maxNumSegments, :2258-2279). It does not update merge.mergeGen: the adopted merge keeps stamp 0.
  3. The adopted merge fails. handleMergeException calls addMergeException (:5040-5041), which records it only if (... mergeGen == merge.mergeGen) (:5821): 1 is not 0, so the failure is dropped.
  4. Still holding the writer lock, the merge thread calls mergeFinish, which wakes every waiter (notifyAll()) and removes the merge from the running list (:5094, :5375-5391); after a failure no follow-up forced merge is registered (:5096-5106). Only after releasing the lock does it publish the tragedy (tragicEvent, :5109-5113; comment "Important that tragicEvent is called after mergeFinish, else we hang").
  5. The woken forceMerge checks the tragedy (not set yet), its failure list (empty) and whether forced merges are pending (none), breaks out of the loop (:2284-2312), passes ensureOpen() because the writer is not closed yet (:2318) and returns normally.
  6. Then the tragedy is set, the writer rolls back to the last commit and closes (:6090-6106). The next call that changes or commits the index (a call guarded by ensureOpen()) throws AlreadyClosedException; close() and rollback() return normally on a closed writer (:1312-1339, :1374-1393, :2576-2584).

Before 5.3.0 merge failures were not tragic, so step 5 happened every time and the writer stayed open. A merge forceMerge registers itself gets the new stamp, so its failure is reported ("background merge hit exception"). The TLA+ model also found a second path that step 2 alone does not explain: a merge that fails and records its failure just before forceMerge clears the list, and is then adopted (model only, not run on a jar).

The numbers

Deterministic JUnit on main, assertions on and off: the merge started by forceMerge's own flush, the merge started by an earlier commit() (both LogDocMergePolicy with merge factor 2) and the default TieredMergePolicy merge of 9 one-document segments all fail with the injected IOException (thrown when the merge creates a file), and forceMerge(1) returns normally with 2, 2 and 9 segments; the control throws IOException: background merge hit exception: _0(11.0.0):c1 _1(11.0.0):c1 into _2 [maxNumSegments=1]. The impact demo's close() returns normally and the index holds 1 of 2 documents. Without test points or InfoStream hooks, the public-API program returned normally in 5 and 7 of 300 runs, and earlier runs in 1/200 to 12/300; another hook-free test in 22 to 84 of 500.

Exposure and likelihood490 words
  • Who is hit. Callers of the blocking forceMerge(n) (forceMerge(int) or forceMerge(n, true)) while a natural merge registered by an earlier commit, flush or NRT reopen, or by forceMerge's own flush, is pending or running. A lone forceMerge(1) after uncommitted adds is enough. A writer that only opens an index and calls forceMerge(1) with no uncommitted changes, like the upgrade idiom in lucene/MIGRATE.md:178-180, is not affected: its flush registers no merge (:4378, :4403-4426), so it waits only for merges it registered itself, whose failure is reported (a probe of that idiom threw in 300 of 300 runs).
  • Default or opt-in. Default: no special configuration is needed; the default ConcurrentMergeScheduler and TieredMergePolicy give 9 segments at return in the deterministic test (its writer config differs from the default only in the test hooks and a 256 MB RAM buffer). The hook-free runs used LogDocMergePolicy with merge factor 2. Not affected: SerialMergeScheduler (the merge runs in the calling thread, so its exception propagates) and forceMerge(n, false), which does not wait.
  • The trigger. An I/O failure in the adopted merge, most realistically a full disk: forceMerge "requires free space that is proportional to the size of the index", 2X to 3X (IndexWriter.java:2195-2197). No other forced merge may be pending or running when the woken call checks; otherwise it waits again, normally sees the tragedy and throws.
  • Consequence: a false success. forceMerge(n) returns with more than n segments, then the writer closes itself and discards every change since the last commit (adds, updates, deletes). The next call that changes or commits the index throws AlreadyClosedException with the tragedy as cause and getTragicException() returns it, but close() returns normally, so change; forceMerge(1); close() can complete without an exception while the change is gone. A hook-free probe ran "delete, or update to a restricted version; forceMerge(1); close()" back to back: every call returned normally and the old document was still live in the committed index in 1 to 17 of 500 attempts per configuration on main, 10.5.2 and 9.12.3, and in 0 of 500 with either fix; in the other attempts an exception was reported (483 to 499 of 500) or close() committed the change first (0 to 2). The default scheduler also throws MergeException in the merge thread (ConcurrentMergeScheduler.java:760-762), which by reading reaches that thread's uncaught-exception handler, by default a stack trace on stderr. Nothing is corrupted; the last commit stays intact.
  • Before 5.3.0 merge failures were not tragic: optimize/forceMerge returned normally with the index unmerged every time and the writer stayed open (executed on 2.3.0 and 5.2.1).
  • How often. Rare: an I/O failure in an adopted merge plus, since 5.3.0, a race the caller cannot influence (0.5% to 17% of attempts with a failing merge in small hook-free harnesses whose failure hits only the merge's own writes; not field rates). No report was found in the 11 years since 5.3.0.

Minimal repro

The test shows that forceMerge(1) adopts the merge its own flush started (_0 + _1) but keeps that merge's old mergeGen, so when the merge fails (IOException "disk full") the failure is dropped and, woken by mergeFinish before the tragedy is published, forceMerge returns normally.

TestForceMergeAdoptedMergeFailure.java (JUnit test)115 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.concurrent.CountDownLatch;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.FilterDirectory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.InfoStream;
import org.apache.lucene.util.ThreadInterruptedException;

public class TestForceMergeAdoptedMergeFailure extends LuceneTestCase {

  public void testFailedMergeStartedByForceMergeFlushIsReported() throws Exception {
    CountDownLatch forceMergeWaiting = new CountDownLatch(1);
    CountDownLatch forceMergeReturned = new CountDownLatch(1);
    FilterDirectory dir =
        new FilterDirectory(new ByteBuffersDirectory()) {
          @Override
          public IndexOutput createOutput(String name, IOContext context) throws IOException {
            if (context.context() == IOContext.Context.MERGE) {
              await(forceMergeWaiting);
              throw new IOException("disk full");
            }
            return super.createOutput(name, context);
          }
        };
    InfoStream infoStream =
        new InfoStream() {
          boolean mergeFinished;

          @Override
          public void message(String component, String message) {
            if (message.equals("forceMergeBeforeWait")) {
              forceMergeWaiting.countDown();
            } else if (message.equals("hit exception during merge")) {
              mergeFinished = true;
            } else if (message.startsWith("hit tragic") && mergeFinished) {
              await(forceMergeReturned);
            }
          }

          @Override
          public boolean isEnabled(String component) {
            return true;
          }

          @Override
          public void close() {}
        };
    ConcurrentMergeScheduler cms =
        new ConcurrentMergeScheduler() {
          @Override
          protected void handleMergeException(Throwable exc) {}
        };
    LogDocMergePolicy mp = new LogDocMergePolicy();
    mp.setMergeFactor(2);
    IndexWriterConfig iwc =
        new IndexWriterConfig().setMergePolicy(mp).setMergeScheduler(cms).setInfoStream(infoStream);
    IndexWriter w =
        new IndexWriter(dir, iwc) {
          @Override
          protected boolean isEnableTestPoints() {
            return true;
          }
        };
    Document doc = new Document();
    doc.add(new StringField("id", "0", Field.Store.NO));
    w.addDocument(doc);
    w.commit();
    w.addDocument(doc);
    Exception thrown = null;
    try {
      w.forceMerge(1);
    } catch (IOException | IllegalStateException e) {
      thrown = e;
    } finally {
      forceMergeReturned.countDown();
      cms.sync();
    }
    assertNotNull(
        "expected forceMerge(1) to throw because the merge of _0 and _1 it waited for failed with "
            + w.getTragicException()
            + ", but it returned normally",
        thrown);
  }

  private static void await(CountDownLatch latch) {
    try {
      latch.await();
    } catch (InterruptedException e) {
      throw new ThreadInterruptedException(e);
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-590/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.index.TestForceMergeAdoptedMergeFailure
Expected vs actual
Expected: forceMerge(1) throws (IOException or IllegalStateException). Actual (-ea and -da, 50/50): it returns normally with the index unmerged; the writer is closed by the tragedy right after.

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

More files, environment, full output53 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected forceMerge(1) to throw because the merge of _0 and _1 it waited for failed with java.io.IOException: disk full, but it returned normally
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.junit.Assert.assertNotNull(Assert.java:713)
       org.apache.lucene.index.TestForceMergeAdoptedMergeFailure.testFailedMergeStartedByForceMergeFlushIsReported(TestForceMergeAdoptedMergeFailure.java:101)

Why it looks unintended

  • The wait loop forwards background merge failures and calls ensureOpen() "so the calling thread will know merging did not complete".
  • The same failure in a merge forceMerge registered itself is reported: IOException: background merge hit exception.
  • The generation filter exists only for forceMerge, yet drops exactly the merges forceMerge adopted, including one its own flush started.
The full argument322 words
  • The wait loop states the intent. "Forward any exceptions in background merge threads to the current thread" (:2293-2294); a tragedy throws "this writer hit an unrecoverable error; cannot complete forceMerge" (:2286-2290); and after the loop: "If close is called while we are still running, throw an exception so the calling thread will know merging did not complete", then ensureOpen() (:2314-2318).
  • The javadoc. @param maxNumSegments maximum number of segments left in the index after merging finishes (:2223); "This call will merge those segments present in the index when the call started" (:2219); @throws IOException if there is a low-level IO error (:2225). Its disk-full paragraph describes a partially merged index for the case "If an Exception is hit" (:2213-2217), that is, when the call throws.
  • The control. A merge forceMerge registered itself, failing the same way, is forwarded as IOException: background merge hit exception on every version where the control ran (2.3.0, 5.2.1, 5.3.0, 9.12.3, 10.5.2 and main).
  • The filter exists only for forceMerge. resetMergeExceptions() has one caller, forceMerge (:2250), and the failure list is consumed only by forceMerge's wait loop (:2292-2303; addMergeException reads it only to skip duplicates, :5821). In this scenario the merges it filters out are exactly those forceMerge has just marked as its own and waits for (maxNumSegmentsMergesPending, :2326-2336); a merge started by forceMerge's own flush cannot predate the call.
  • Nothing accepts it. No javadoc, CHANGES.txt or MIGRATE.md entry, test or issue describes a normal return after a failed adopted merge; TestIndexWriterExceptions.testForceMergeExceptions and TestIndexWriterOnDiskFull tolerate exceptions but never check the segment count after a normal return.
  • Precedent. LUCENE-1376 (#2450, 2008), "sometimes if a BG merge hits an exception, optimize() will fail to forward the exception", and LUCENE-2618 (#3692, 2010), an adoption gap for concurrently started natural merges, were both fixed as bugs. This case survives both fixes.
The devil's-advocate check246 words

The strongest case for "intended" (from the intended-behaviour checker and the cross-model refute review): mergeGen has deliberately kept earlier merges' failures out since 2007; publishing the tragedy after mergeFinish is deliberate (LUCENE-6579); the javadoc never says every background failure reaches the caller and it allows a partial merge; the writer still reports the tragedy at the next call and in the merge thread; close() doing nothing on a closed writer matches Closeable; forceMerge(n) may legitimately leave more than n segments when a policy skips segments; and the deterministic test needs test points and an @lucene.internal InfoStream hold.

Why it fails: forceMerge reclassifies the adopted merges as its own and waits for them, and a merge started by its own flush removes any "not your merge" reading. The LUCENE-6579 comment requires only that the close follows mergeFinish; onTragicEvent only sets a field, the close is maybeCloseOnTragicEvent (:6068-6106). The partial-merge sentence is about a call that throws. A later report does not make a return correct whose postcondition is false. The 2 or 9 segments are not a policy decision: the follow-up forced merge is skipped only because the merge failed (:5096-5106). And the hook-free public-API program fails without any test point or InfoStream hook, while upstream tests already use the same test points (TestConcurrentMergeScheduler.java:1007,1014). The refute reviewer agreed with the root cause ("mechanically credible") and called its own not-real verdict procedural; it could not run code. Verdict: not intended.

Suggested fix

Recommended Publish the tragedy before mergeFinish wakes the waiters (validated)

Publish the tragedy before mergeFinish wakes the waiting forceMerge (+5 lines in IndexWriter.merge; the writer still closes after mergeFinish). The tests, 24 closest suites and all 8,894 tests of :lucene:core:test pass. A waiting forceMerge then gets IllegalStateException for an ordinary merge failure. A 4-line alternative sets mergeGen on adoption but misses a second path the model found.

} catch (Throwable t) {
  if (t instanceof MergePolicy.MergeAbortedException == false) {
    // publish the tragedy before mergeFinish() wakes threads that wait for this merge;
    // the writer is still closed only after mergeFinish (tragicEvent below)
    onTragicEvent(t, "merge");
  }
  handleMergeException(t, merge);
}
All options and trade-offs3 options · 822 words

Two fix sketches from the hunt, both validated through gradle in scratch worktrees of a9779ef3c193. They differ in what they cover and in which exception a waiting forceMerge gets. Present both.

Publish the tragedy before mergeFinish wakes the waiters (validated) (recommended)

+5 lines in IndexWriter.merge (IndexWriter.java:5084): call onTragicEvent in the inner catch, so the tragedy is set before mergeFinish calls notifyAll(); tragicEvent (which closes the writer) stays after mergeFinish, as LUCENE-6579 requires.

} catch (Throwable t) {
  if (t instanceof MergePolicy.MergeAbortedException == false) {
    // publish the tragedy before mergeFinish() wakes threads that wait for this merge;
    // the writer is still closed only after mergeFinish (tragicEvent below)
    onTragicEvent(t, "merge");
  }
  handleMergeException(t, merge);
}

Trade-offs

  • Closes both paths in the TLA+ model: the dropped failure and the failure cleared by the reset (configs fixtrag1 4,847 states, two callers with the same n 91,094, wider bounds up to 38,896,974; invariants hold; two callers with different n still violate F1 through the separate LFV-592).
  • For an ordinary failure (merge aborts are skipped by the patch) of a merge it waits for, a waiting forceMerge then throws IllegalStateException: this writer hit an unrecoverable error; cannot complete forceMerge (cause: the merge's exception) instead of, sometimes, the IOException: background merge hit exception it gets today; the control shows this change. As today, an InfoStream whose message() throws makes onTragicEvent skip setting the field (:6075-6083).
  • onTragicEvent runs twice per failed merge (the second compareAndSet is a no-op, but "hit tragic" is logged twice), and other threads can see the tragedy while the merge is still in the running list; by reading, a concurrent rollback then waits in abortMerges until the merge leaves it. The suites showed no hang.
  • The test's hold then sits before mergeFinish, so each fixed test waits out its 10 s bound.

What the checks validated

  • Gradle, scratch worktree of a9779ef3c193 (removed afterwards), 2026-10-27: the paste-ready 4-test class and the impact demo pass with assertions on and off (forceMerge throws IllegalStateException: ... cannot complete forceMerge; pass 4), and an earlier version of the tests with tests.iters=10 (seed CAFEBABE): 50 of 50.
  • 24 closest org.apache.lucene.index suites (TestIndexWriter, TestIndexWriterExceptions, TestIndexWriterExceptions2, TestIndexWriterOnDiskFull, TestIndexWriterForceMerge, TestConcurrentMergeScheduler, TestTragicIndexWriterDeadlock, TestThreadedForceMerge, TestRollback, TestAddIndexes and others): 376 tests, 0 failures, 19 skipped, with assertions on and off.
  • All of :lucene:core:test (seed DEADBEEF): 750 classes, 8,894 tests, 0 failures, 0 errors, 322 skipped; :lucene:core:check -x test passes with the paste-ready tests.
  • Earlier, on the jar: the hook-free program 0 of 200 normal returns; the hook-free probes 0 of 500 silent losses (delete and update modes); the finder's hooked 4-test JUnit 4 of 4.

Set mergeGen when forceMerge adopts a merge (validated, smaller)

+4 lines in the adoption loops (IndexWriter.java:2260-2278): merge.mergeGen = mergeGen; next to merge.maxNumSegments = maxNumSegments; for pending and running merges, so an adopted merge's failure reaches the wait loop like that of a merge forceMerge registered itself.

for (final MergePolicy.OneMerge merge : pendingMerges) {
  merge.maxNumSegments = maxNumSegments;
  // this call now waits for the merge: report its failure to this call
  merge.mergeGen = mergeGen;
  ...
for (final MergePolicy.OneMerge merge : runningMerges) {
  merge.maxNumSegments = maxNumSegments;
  merge.mergeGen = mergeGen;

Trade-offs

  • Smallest change; adopted merges then behave exactly like merges forceMerge registers (IOException: background merge hit exception when the waiter is first, IllegalStateException when the tragedy is).
  • Misses the model's second path: a merge that fails and records its failure just before forceMerge clears the list, and is adopted afterwards (config fixgen1, not run on a jar). A possible complement, untested: in the adoption loops, also add a merge whose getException() is already set to the failure list.
  • Keeps the writer-wide mergeGen and failure list that concurrent forceMerge calls share: by reading, a second call's reset can still clear a failure already recorded for the first (the model's two-caller stock config two_stock_same shows that clearing; fix A was not model-checked with two callers).

What the checks validated

  • Gradle, scratch worktree of a9779ef3c193 (removed afterwards), 2026-10-27: the paste-ready 4-test class and the impact demo pass with assertions on and off (forceMerge throws IOException: background merge hit exception; pass 4), and an earlier version of the tests with tests.iters=10: 50 of 50.
  • The same 24 closest suites: 376 tests, 0 failures, 19 skipped, assertions on and off. All of :lucene:core:test: 750 classes, 8,894 tests, 0 failures, 322 skipped; :lucene:core:check -x test passes with the paste-ready tests.
  • Earlier, on the jar: the independent reproducer's shadow fix 0 of 500 normal returns hook-free; the hook-free probes 0 of 500 silent losses.

Other directions (untested)

Track the merges a forceMerge call waits for and check each one's getException() when it finishes, as MergePolicy.MergeObserver does for forceMergeDeletes, instead of the generation-filtered list; or drop the generation filter for merges that have maxNumSegments set.

Trade-offs

Larger changes to the wait protocol; not built or run.

Downstream note

Not a security issue (a full disk triggers it, not untrusted input), but a service that runs delete; forceMerge(1); close() and trusts the normal return can believe a deletion took effect when it was rolled back.

How it was found

Semi-formal: the mechanism was first read from the code while planning a TLA+ model of IndexWriter's forced-merge wait protocol; the TLC model checker then produced a 12-state counterexample with one caller, one natural merge and one I/O failure. It was replayed on the stock jar as a JUnit, and hook-free programs hit it too.

The method in full239 words

Method: TLA+ model checking (TLC) of the forced-merge wait protocol, at the granularity of the writer's synchronized blocks (ForceMergeWait.tla), with 1-2 callers, 1-2 merge threads, a flushing thread, deleteAll and merge I/O failures as actions, and fix switches.

  • The hunt unit's plan already read the mechanism from the code, so this is semi-formal, not a blind find. TLC then witnessed it: invariant F1, "a normal return leaves the segments present at the call merged into at most n", is violated by a 12-state trace (config stock1).
  • TLC also found a second path that the mergeGen fix alone misses (fixgen1, a 12-state trace), and showed the tragedy-first fix clean (fixtrag1, 4,847 states; two same-target callers 91,094; wider bounds on a separate host up to 38,896,974 states). The model abstracts merge-policy choices and timed waits and does not model close(), commit() or a throwing InfoStream: it supports the wait-protocol argument, not the full safety of either patch.
  • The trace was replayed on the stock jar as the JUnit (test points, MockDirectoryWrapper fault injection, an InfoStream hold). A stress test without test points or InfoStream hooks returned normally in 1/100 and 5/100 runs, and the public-API program in 2/200 and 5/200 (0/200 with a fix).
  • The model's liveness check also led to two sibling bugs with NoMergeScheduler (LFV-586, LFV-587); an overlap of two forceMerge calls with different n is the separate LFV-592.

Prior reports, history and checks

Prior reports

No prior report: 301 queries over GitHub, JIRA, the mailing lists and downstream projects plus all 406 open PRs (2026-10-26). Closest: LUCENE-1376 (2008), the same symptom class with a different cause that was fixed long ago.

Searches5 searches

No report, issue, PR or list discussion describes this bug; every query on its internal identifiers returned 0 results.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories2026-10-26a scripted prior-report search, 6 runs: 48 issue/PR queries, 9 commit queries, 6 downstream queries (identifiers addMergeException, resetMergeExceptions, mergeGen, maxNumSegmentsMergesPending, tragicEvent, "background merge hit exception", "cannot complete forceMerge"; optimize/forceMerge symptom phrasings); 69 manual searches, including elastic/elasticsearch, OpenSearch, Solr and the .NET portno report of this bug; every query on the internal identifiers returned 0 results
Open apache/lucene PRs2026-10-26changed files of all 406 open PRs; patches of the 20 touching IndexWriter.java, ConcurrentMergeScheduler.java or MergePolicy.java scanned#16571, #16846 and #16888 edit nearby code; none changes the adoption loop, the failure filter or the mergeFinish/tragedy order
JIRA archive2026-10-2616 tool queries and 18 hand-written JQL queries; 30 issues read in fullLUCENE-1376 (2008: optimize did not forward a failure because of the loop structure; fixed), LUCENE-1397, LUCENE-2618 (2010: adoption gap, fixed); LUCENE-845 and LUCENE-6579 are the origins, not reports
Mailing lists (dev@, java-user@, issues@, commits@, builds@)2026-10-26125 queries; 12 threads read in fullonly threads where optimize/forceMerge did throw "background merge hit exception"
Web2026-10-267 querieselastic/elasticsearch#102594 (a force merge ends with more than one segment, no merge failure reported; symptom level only)

History

Introduced by LUCENE-845 (299d6357dd29, 2007), which added background merges, and first released in 2.3.0 (2008); deterministic until 5.2.x, a race since 5.3.0 (LUCENE-6579). Executed from 2.3.0 to 10.5.2 and on main; never fixed.

Versions tested9 rows

Introduced by 299d6357dd29 (2007-09-18, svn r576798, by mikemccand), "LUCENE-845, LUCENE-847, LUCENE-870: factor MergePolicy & MergeScheduler out of IndexWriter, improve overall concurrency of IndexWriter, and add ConcurrentMergeScheduler": it added mergeGen, the failure filter, resetMergeExceptions and the loop that adopts pending and running merges without updating their generation. First release 2.3.0 (2008-01-24, as optimize(); renamed forceMerge() in 3.5.0). LUCENE-6579 (#7637, 5.3.0) made merge failures tragic and calls tragicEvent after mergeFinish: the deterministic false success became a race and the writer now closes right after. It narrowed the defect; it did not introduce it. IndexWriter.java is unchanged on upstream main, branch_10x and branch_10_6 since #16581 (2026-09-21), which does not touch these lines.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193, ea57360912ed affectedJUnit 4 run / 3 fail, assertions on and off; hook-free program 5/300 and 7/300; history harness 3/10 without the hold
10.5.2, 9.12.3 affectedJUnit 4 run / 3 fail (9.12.3 default policy: 11 segments at return); history harness fails; control passes
9.0.0, 8.11.4, 8.6.0 affectedJUnit 4 run / 3 fail (test points exist since 8.6.0; a copy with the pre-9.1 test-framework imports)
6.6.6, 7.7.3, 8.5.2 affectedhistory harness with the hold: returns normally, then the tragedy closes the writer
5.3.0 racereturns normally, then the tragedy closes the writer; 6/10 without the hold
2.3.0, 5.2.1 deterministicreturns normally with 2 segments every time, writer stays open; control throws "background merge hit exception"
2.4.0 to 4.10.4 by readingthe same lines (2.4.0, 2.9.2, 3.0.1, 3.6.2, 4.0.0, 4.10.4 read)
2.2.0 not affectedoptimize() exists, but there are no background merges; the harness does not compile
main + either fix sketch not affectedforceMerge throws in every test; 0 normal returns hook-free

Independent checks

Confirmed by an adjudication (high confidence) after an independent reproducer with its own test and fix, cross-model reviews (OpenAI GPT-5.6, reading only) and an intended-behaviour devil's advocate; a credibility audit re-ran it at a new seed, and this doc re-ran it through gradle.

Checks and verdicts309 words

Every AI tool check that ran Java reproduced it on main; the cross-model reviews could not run code (separate runs re-ran the JUnit: 4 run / 3 fail).

Each independent check and its verdict
CheckVerdictWhat it found
Independent reproducer realOwn JUnit written from the claim fails 3 run / 2 fail on main (-ea, -da), 10.5.2 and 9.12.3; hook-free 22-84/500, each followed by a close() that returned normally with 1 of 2 documents; its mergeGen shadow fix gives 0/500.
Refute review (OpenAI GPT-5.6, reading only) not real, low confidence, proceduralCould not run code; agreed with the root cause; objected to the test hooks, which the hook-free program answers.
Intended-lens review (OpenAI GPT-5.6, reading only) and its verifier runs real, mediumRe-runs fail on main (-ea, -da) and 10.5.2; hook-free 1/200 and 9/200.
Intended-behaviour devil's advocate real, medium-highNo javadoc, CHANGES, MIGRATE entry, test or issue accepts it; re-runs fail on main, 10.5.2 and 9.12.3; hook-free 12/300.
Adjudication confirmed, highRe-ran the independent reproduction's JUnit at seed 7A11E5ED (3 run / 2 fail, -ea and -da) and the hook-free program (6/200); set the impact class to wrong error.
Credibility audit keepSeed A0D17004: 4 run / 3 fail, -ea and -da; hook-free 4/300; impact not inflated.
This doc's gradle run reproduces; both fixes passStock: 3 bug tests and the impact demo fail, the control passes, assertions on and off; plain-java hook-free run 5/300 and 7/300.
Fact checks (two) and diagram check bug holds; text fixedAn AI tool fact check and a cross-model one (OpenAI GPT-5.6, reading only) found wording and citation errors, not a change in the conclusion; the diagram check found the test's fault injection failing a read. The test now fails only new files, re-run on main and 5 releases with the same results.

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

Finding record

LFV-590, snapshot of 2026-11-06

Registry title
IndexWriter.forceMerge(n) can return normally with more than n segments when a merge that was already pending or running at the call fails: forceMerge adopts that merge without updating its mergeGen, so addMergeException drops the failure, and the tragic event is published only after mergeFinish has woken the waiting forceMerge
Area
index / IndexWriter forced merge (forceMerge wait loop, mergeExceptions and mergeGen, tragic events)
Priority
Low, rank 285: Needs a merge I/O failure such as a full disk plus a lost race, and the writer then closes, so the next modifying call fails loudly.
Impact class
wrong error
Impact note
Hits any application whose blocking forceMerge(n) adopts a merge that was registered before the call (by an earlier commit, flush or NRT reopen, or by forceMerge's own flush) when that merge then fails with an I/O error such as a full disk; no special configuration is needed, the default ConcurrentMergeScheduler and TieredMergePolicy reproduce it (SerialMergeScheduler is not affected), no scale is needed, and since 5.3.0 the waiting call must also win a short race against the tragedy publication (0.5% to 17% of failing attempts in small synthetic hook-free harnesses, not a field rate): forceMerge then returns normally with the index unmerged instead of throwing, the documented tragic rollback closes the writer a moment later, and the next call that changes or commits the index throws AlreadyClosedException, while close() and rollback() return normally, so 'change; forceMerge(1); close()' can report success while every change since the last commit is lost (executed for an added document, a delete and a restricting update); nothing is corrupted and the last commit stays intact.
Summary
You call forceMerge(1) while a background merge started by an earlier commit or flush (or by forceMerge's own flush) is still pending or running, and that merge fails, for example because the disk is full. You expect forceMerge to throw, as it does ('background merge hit exception') when the failing merge is one it registered itself. Instead it can return normally with the index still unmerged (2 segments, or 9 with the default TieredMergePolicy): the adopted merge keeps its old mergeGen, so its failure is dropped, and the tragic event that closes the writer is published only after mergeFinish has woken the waiting call.
Introduced
299d6357dd296f87a099fadd0764292b28dbde47 'LUCENE-845, LUCENE-847, LUCENE-870: factor MergePolicy & MergeScheduler out of IndexWriter, improve overall concurrency of IndexWriter, and add ConcurrentMergeScheduler' (Subversion r576798, by mikemccand; pre-GitHub, no PR), which added mergeGen, the addMergeException generation filter, resetMergeExceptions and the optimize() loop that marks pending and running merges as its own without updating their mergeGen [read: git blame of IndexWriter.java:3039-3042, :5218 and :5821 on main and the commit's own tree in the full-history clone; exec at release granularity: 2.2.0 has no background merges (the repro does not compile), 2.3.0 fails deterministically]. LUCENE-6579 (branch_5x aaf1c49b6097, trunk 926397824e9f; released in 5.3.0) made merge failures tragic and calls tragicEvent after mergeFinish; that turned the deterministic false success into a race (it narrowed the defect, it did not introduce it). (2007-09-18)
First affected version
2.3.0 [exec: optimize(1) returned normally with 2 segments after the adopted merge failed, -ea and -da, deterministic; the own-merge control threw 'background merge hit exception']; 2.2.0 has no background merges [exec: the repro does not compile]
Affected versions
every release from 2.3.0 through 10.6.0 (10.6.0 by reading), including 9.12.3 (the latest 9.x), and main a9779ef3c193; the previous hunt main ea57360912ed too [exec]; never fixed. [exec, history harness without test points or test framework] 2.3.0 and 5.2.1: deterministic false success with the writer left open (5.2.1: 5/5 without any hold); 5.3.0, 6.6.6, 7.7.3, 8.5.2, 9.12.3, 10.5.2 and main: forceMerge returns normally and the tragedy closes the writer right after (deterministic with an InfoStream hold; without it 6/10 on 5.3.0 and 3/10 on main); the own-merge control passes on 2.3.0, 5.2.1, 5.3.0, 9.12.3, 10.5.2 and main. [exec, the main-branch JUnit, whose test points exist since 8.6.0] 8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main (-ea and -da) each 4 run / 3 fail with the control passing; the independent reproduction and the intended-behaviour check repeated this on main, 10.5.2 and 9.12.3, the cross-model review on main and 10.5.2. [read] 2.4.0, 2.9.2, 3.0.1, 3.6.2, 4.0.0 and 4.10.4 have the same lines. IndexWriter.java is unchanged on upstream main (a95c754ee003), branch_10x and branch_10_6 since #16581 (2026-09-21), which does not touch these lines.
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: code reading; also reached by TLA+/TLC
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record, verdict new; 301 queries): a scripted prior-report search over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@, commits@ and builds@ lists and the JIRA archive, plus 69 manual GitHub searches, 18 JIRA searches, 7 web searches and the changed files of all 406 open apache/lucene PRs (20 touch IndexWriter.java, ConcurrentMergeScheduler.java or MergePolicy.java; #16571, #16846 and #16888 edit nearby code, but none changes the adoption loop, addMergeException/resetMergeExceptions or the mergeFinish/tragedy order); every query on the internal identifiers (addMergeException, resetMergeExceptions, maxNumSegmentsMergesPending, merge.mergeGen, forceMergeBeforeWait) returned 0 results. Closest prior art, all fixed long ago and with different root causes: #2450 / LUCENE-1376 (2008: optimize() failed to forward a background merge exception because of the loop structure; the fixed loop is the one main still has, and this bug happens with it), #2471 / LUCENE-1397 (2008: a failed merge was recorded before its cause was set), #3692 / LUCENE-2618 (2010: a natural merge started concurrently with optimize was not marked as an optimize merge; the fix upgrades maxNumSegments, not mergeGen). Origins, not reports: LUCENE-845 (mergeGen, 2007) and #7637 / LUCENE-6579 (tragicEvent after mergeFinish). Symptom-level only, no mechanism: elastic/elasticsearch#102594 (a force merge completes with more than one segment; no merge failure reported; its maintainers point to relocations and refreshes).
Lucene commit tested
main a9779ef3c193 (2026-10-26): the 4-test JUnit and the impact demo through gradle with assertions on and off (3 bug tests and the demo fail, the control passes), and through the repro harness at seeds DEADBEEF and A0D17004; the independent reproducer's own JUnit at seed 7A11E5ED; the hook-free public-API program with plain javac/java and through the harness; 10.5.2, 9.12.3, 9.0.0, 8.11.4 and 8.6.0 with the JUnit; 2.3.0, 5.2.1, 5.3.0, 6.6.6, 7.7.3, 8.5.2, 9.12.3 and 10.5.2 with a history harness without test points (2.2.0 has no background merges; the harness does not compile there); the previous hunt main ea57360912ed; the exact gradle commands of the repro section on a fresh stock worktree; both fix sketches in scratch worktrees of a9779ef3c193 (the tests, 24 closest suites, all of :lucene:core:test, :lucene:core:check -x test), removed afterwards; after the checks, the paste-ready tests again with their fault injection narrowed to files the merge creates (stock at 4 seeds, both fixes, assertions on and off, check -x test; the JUnit on 10.5.2, 9.12.3, 9.0.0, 8.11.4 and 8.6.0).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2187-2227 (at a9779ef3c193) (forceMerge javadoc: 2192-2193 'only call this if the index is static', 2195-2197 2X/3X free space, 2219 'This call will merge those segments present in the index when the call started', 2223 '@param maxNumSegments maximum number of segments left in the index after merging finishes', 2225 '@throws IOException'), :2248 (flush before the reset), :2250 (resetMergeExceptions), :2258-2279 (adoption loops set maxNumSegments but not mergeGen), :2284-2318 (wait loop: tragedy check 'cannot complete forceMerge', forwarding 'background merge hit exception', maxNumSegmentsMergesPending, then ensureOpen with the comment 'so the calling thread will know merging did not complete'), :3039-3042 (resetMergeExceptions: clear, mergeGen++), :5218 (registerMerge: merge.mergeGen = mergeGen), :5819-5824 (addMergeException keeps a failure only if mergeGen == merge.mergeGen), :5084-5114 (merge(): handleMergeException, mergeFinish in finally under the lock, then tragicEvent outside it, comment 'Important that tragicEvent is called after mergeFinish, else we hang'), :5375-5379 (mergeFinish: notifyAll), :6076-6080 (onTragicEvent: 'hit tragic' message, then tragedy.compareAndSet)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2248, (at a9779ef3c193) :2250, :2258-2279, :2284-2318, :3039-3042, :5040-5041, :5084-5114, :5218, :5375-5391, :5819-5824, :6068-6106, :1312-1393
Who is affected
  • lucene/MIGRATE.md:178-180; (at a9779ef3c193) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2195-2197, :1312-1393, :913-917, :2576-2584, :4378, :4403-4426; ConcurrentMergeScheduler.java:747-762
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2213-2225, (at a9779ef3c193) :2286-2318, :2326-2336, :5096-5106, :6068-6106
  • lucene/core/src/test/org/apache/lucene/index/TestConcurrentMergeScheduler.java:1007,1014; (at a9779ef3c193) TestIndexWriterExceptions.java:1064-1108 (testForceMergeExceptions)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5037-5055 (at a9779ef3c193) (handleMergeException records aborts too), :6075-6083 (onTragicEvent)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5084-5114, (at a9779ef3c193) :2258-2279, :6068-6106

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

Repro files

About the diagrams

Summary card (bug.png); the values on screen come from the executed runs cited in this doc (the 9 segments and the control from the same test class, the rates from the hook-free harnesses, the versions from the history runs).

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.