All findings · Lucene finding · LFV-592 · overlapping-forcemerge-calls-share-maxnumsegments

Overlapping forceMerge calls share one target: forceMerge(1) can return with 2 segments

IndexWriter keeps a single forced-merge target for the whole writer. When another thread calls forceMerge(2) while a forceMerge(1) is still merging, the second call overwrites that target, even on running merges, and forceMerge(1) returns normally with 2 segments and no error.

Summary

Priority
Low, rank 1005A forceMerge(1) call can return with 2 segments when it overlaps a forceMerge(2) call on another thread, which is operational only and needs an unusual concurrent pattern.
Impact
minor
Since
2.3.0 (2007)introduced 2007-11-30 · over 18 years
Affected
2.3.0 to 10.6.0 and main
Status
documented
Found by
formal: TLA+ model checking of the forceMerge wait loop

AI-assisted page: this finding was first signalled by a formal tool's own output 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

Every forceMerge(n) call overwrites one writer-wide target, also on merges an earlier call started, and a waiting call returns once no forced merge is left, without checking its own n.

Background: how a blocking forceMerge works

  1. forceMerge(n) asks the merge policy for merges that bring the index down to at most n segments and, by default, waits until they are done.
  2. A policy may need several rounds. LogMergePolicy merges at most mergeFactor segments at a time; when a forced merge finishes, IndexWriter asks the policy again with that merge's target (a cascade).
  3. The waiting call sleeps in a loop and leaves it when no forced merge is pending or running.
Diagram of the repro at Apache Lucene main 645d4fe14a20: three one-document segments _0, _1, _2 and two threads on one IndexWriter. Thread A calls forceMerge(1), which plans the merge of _1 and _2 (round 1 of 2). While that merge runs, thread B calls forceMerge(2), which sets the writer-wide target and the running merge's target to 2. The merge produces _3; re-planning with target 2 finds nothing to do; A's wait loop sees no forced merge left and returns with 2 segments, _0 and _3, where 1 was expected. With two forceMerge(1) calls the second round merges _0 and _3 into _4.
The repro's three segments: forceMerge(1) plans _1 + _2; while that merge runs, forceMerge(2) on another thread overwrites the shared target, the cascade stops at _0 and _3, and forceMerge(1) returns with 2 segments. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Three one-document segments, _0, _1 and _2, with LogDocMergePolicy at mergeFactor 2: forceMerge(1) needs two rounds, _1 + _2 -> _3, then _0 + _3 -> _4.
  2. Thread A calls forceMerge(1). While its first merge runs, thread B calls forceMerge(2) on the same writer.
  3. You expect A to return with 1 segment: maxNumSegments is the "maximum number of segments left in the index after merging finishes", IndexWriter allows concurrent calls, and 1 segment also meets B's target.
  4. Instead B writes its target, 2, into the shared state and into A's running merge. When that merge ends, re-planning with 2 finds nothing to do, and A's wait loop, which only checks that no forced merge is left, returns: 2 segments (_0, _3), no error.
  5. Nothing is lost, searches are correct and a later forceMerge(1) finishes the job. The reverse overlap (2 first, then 1) and two overlapping forceMerge(1) calls both end with 1 segment.

Who is affected

Applications that call blocking forceMerge with different targets on one IndexWriter from two threads, the later call with the larger target, while the first still needs another merge round: LogByteSizeMergePolicy or LogDocMergePolicy with more than mergeFactor segments, or TieredMergePolicy during a natural merge (shown with floorSegmentMB lowered for tiny test segments). Rare.

Mechanism, terms and numbers577 words

IndexWriter keeps one forced-merge state for the whole writer: a segmentsToMerge map and an int mergeMaxNumSegments. Every forceMerge(n) call resets that state and writes its own n into every pending and running merge, including the merges an earlier call that is still waiting started. A finished forced merge re-plans with its own, now overwritten, target, and a waiting caller returns as soon as no forced merge is pending or running; it never compares the result with its own n.

So forceMerge(1), overlapped by forceMerge(2) on another thread while it still needs another merge round, returns normally with 2 segments (3 with an overlapping forceMerge(3)). No document is lost, searches are correct and CheckIndex is clean. It needs the first call to take more than one merge round: LogByteSizeMergePolicy or LogDocMergePolicy with more than mergeFactor segments, or the default TieredMergePolicy while a natural merge runs (shown with floorSegmentMB lowered so the tiny test segments behave like real ones; with the default floor the same tiny-segment run gave 1 segment); the default policy on a static index of small segments is not affected. Introduced by LUCENE-982 (2007, first released in 2.3.0); every release from 2.3.0 to 10.6.0 and main is affected. Keeping the stricter target while an earlier call is still active fixes the repro and passes the closest suites.

The bug, step by step (main 645d4fe14a20, IndexWriter.java)

  1. One forced-merge state per writer. segmentsToMerge ("used by forceMerge to note those needing merging") and mergeMaxNumSegments are plain fields of the writer (:377-379).
  2. Each call overwrites it. forceMerge(int, boolean) (:2237), under the writer's lock (:2249), clears and refills segmentsToMerge (:2251-2255), sets mergeMaxNumSegments = maxNumSegments (:2256) and then, under the comment "Now mark all pending & running merges for forced merge" (:2258-2259), sets merge.maxNumSegments = maxNumSegments on every pending (:2261) and running (:2271) merge. The merge that an earlier, still-waiting forceMerge(1) started now carries the target 2.
  3. New merges take the latest target. maybeMerge(..., MergeTrigger.EXPLICIT, maxNumSegments) (:2281) asks findForcedMerges with this call's target and stamps each returned merge with it (:2489); a merge registered later over a segment in segmentsToMerge gets mergeMaxNumSegments (:5199-5200).
  4. The cascade uses the overwritten target. A committed forced merge adds its new segment to segmentsToMerge (:5020-5024), and the merge thread re-plans with updatePendingMerges(mergePolicy, MergeTrigger.MERGE_FINISHED, merge.maxNumSegments) (:5105). With target 2 and segments _0(1), _3(2) left, LogMergePolicy returns no merge.
  5. The waiter checks "any forced merge left", not its own target. The wait loop (:2284-2312) waits while maxNumSegmentsMergesPending() (:2305) is true; that method (:2326-2336) only asks whether a pending or running merge has maxNumSegments set. Once the last forced merge is done, forceMerge(1) returns normally. Nothing compares the segment count with its n.

Executed (stock jars)

scenarioforceMerge(1) returns with
forceMerge(1), then an overlapping forceMerge(2)2 segments, no error
forceMerge(1), then an overlapping forceMerge(3)3 segments, no error
two overlapping forceMerge(1) calls1 segment
forceMerge(2) first, then an overlapping forceMerge(1)1 segment (both calls)
stock LogByteSizeMergePolicy, 40 segments, no hooks, timed (adjudication)more than 1 segment in 20/20 trials, assertions on and off; control 0/20
default TieredMergePolicy, static index of small segments1 segment: not affected (one merge round)
TieredMergePolicy while a natural merge runs (floorSegmentMB lowered for tiny test segments)2 segments
Exposure and likelihood250 words
  • Who is hit. Code that runs blocking forceMerge calls with different targets on one writer from two threads, for example a "merge to 1" job and a "merge to at most N" maintenance task sharing a writer, when the later call has the larger target and starts while the first call's forced merges are still pending or running.
  • Default vs opt-in. Stock components, no opt-in feature, no scale (3 one-document segments are enough). The first call must need more than one merge round: LogByteSizeMergePolicy or LogDocMergePolicy (stock, not the default) with more than mergeFactor segments; with the default TieredMergePolicy it was reproduced only when forceMerge(1) starts while a natural merge over some segments runs (executed with floorSegmentMB lowered so tiny test segments behave like real ones). A forceMerge(1) with TieredMergePolicy on a static index of small segments merges in one round and was not affected; larger TieredMergePolicy indexes were not tested.
  • What goes wrong. forceMerge(1) reports success with 2 (or 3) segments, so code that relies on one segment afterwards is wrong. A segment that was not re-merged keeps its deleted documents on disk until a later merge. In an executed probe deleted documents stayed invisible to search, every live document was found once with its own stored value, CheckIndex was clean, and a second forceMerge(1) reached 1 segment with no deletions.
  • Likelihood: rare. forceMerge is usually run once by one task on a static index, and no user report was found in the nearly 19 years since 2.3.0 (2008).

Minimal repro

One JUnit test in lucene/core, public API: three one-document segments, LogDocMergePolicy (mergeFactor 2, natural merges off) and a ConcurrentMergeScheduler that holds each merge until the second call has planned. The main thread calls forceMerge(1); a second thread then calls forceMerge(2). Two latches fix the order; no sleeps.

TestOverlappingForceMerge.java (one test)97 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.Map;
import java.util.concurrent.CountDownLatch;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.ThreadInterruptedException;

public class TestOverlappingForceMerge extends LuceneTestCase {

  public void testForceMergeOneOverlappedByForceMergeTwo() throws Exception {
    Thread mainThread = Thread.currentThread();
    CountDownLatch firstCallPlanned = new CountDownLatch(1);
    CountDownLatch secondCallPlanned = new CountDownLatch(1);
    LogDocMergePolicy mp =
        new LogDocMergePolicy() {
          @Override
          public MergeSpecification findMerges(
              MergeTrigger trigger, SegmentInfos infos, MergeContext context) {
            return null;
          }

          @Override
          public MergeSpecification findForcedMerges(
              SegmentInfos infos,
              int maxSegmentCount,
              Map<SegmentCommitInfo, Boolean> segmentsToMerge,
              MergeContext context)
              throws IOException {
            (Thread.currentThread() == mainThread ? firstCallPlanned : secondCallPlanned)
                .countDown();
            return super.findForcedMerges(infos, maxSegmentCount, segmentsToMerge, context);
          }
        };
    mp.setMergeFactor(2);
    ConcurrentMergeScheduler cms =
        new ConcurrentMergeScheduler() {
          @Override
          protected void doMerge(MergeSource source, MergePolicy.OneMerge merge)
              throws IOException {
            try {
              secondCallPlanned.await();
            } catch (InterruptedException e) {
              throw new ThreadInterruptedException(e);
            }
            super.doMerge(source, merge);
          }
        };
    Directory dir = newDirectory();
    IndexWriter w =
        new IndexWriter(dir, new IndexWriterConfig().setMergePolicy(mp).setMergeScheduler(cms));
    for (int i = 0; i < 3; i++) {
      w.addDocument(new Document());
      w.flush();
    }
    Thread second =
        new Thread(
            () -> {
              try {
                firstCallPlanned.await();
                w.forceMerge(2);
              } catch (Exception e) {
                throw new RuntimeException(e);
              }
            });
    second.start();
    w.forceMerge(1);
    int segments;
    try (DirectoryReader r = DirectoryReader.open(w)) {
      segments = r.leaves().size();
    }
    second.join();
    w.close();
    dir.close();

    // forceMerge(maxNumSegments): "maximum number of segments left in the index after merging finishes"
    assertEquals("segments when forceMerge(1) returned", 1, segments);
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20 (or upstream main)
cp $REPRO/TestOverlappingForceMerge.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestOverlappingForceMerge -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
1 segment when forceMerge(1) returns.
Observed
segments when forceMerge(1) returned expected:<1> but was:<2>, with assertions on and off; neither call throws.

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

More files, environment, full output311 words

Two threads, because concurrency is the bug; deterministic. LogDocMergePolicy (stock, not the default) is subclassed only to turn natural merges off (findMerges returns null) and to signal when each forceMerge call has planned (findForcedMerges). The second thread starts forceMerge(2) once the main thread has planned its first round; IndexWriter plans under its own lock, so the merge _1 + _2 is registered by then (pending or already running; the reset overwrites both). The merge thread holds that merge until forceMerge(2) has planned, i.e. until after its reset. The segment count is read on an NRT reader right after forceMerge(1) returns. Ten random-seed iterations failed 10/10 with the same message.

As the regression test it is a good basis: google-java-format wants the comment above the assertion wrapped (./gradlew tidy does it); ecj and forbidden-APIs lint pass. The latch waits and join() are unbounded, so a fix that made forceMerge(2) wait for forceMerge(1) would hang the test instead of failing it, and an exception in the second thread is not asserted on the main thread.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): affected; the same test also fails on the 10.6.0 and 9.12.3 jars
jdkOpenJDK 25.0.3
osLinux, aarch64

Observed output

TestOverlappingForceMerge > testForceMergeOneOverlappedByForceMergeTwo FAILED
    java.lang.AssertionError: segments when forceMerge(1) returned expected:<1> but was:<2>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:2905AE9AAA72FA49]: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.TestOverlappingForceMerge.testForceMergeOneOverlappedByForceMergeTwo(TestOverlappingForceMerge.java:95)

:lucene:core:test (FAILURE): 1 test, 1  failure

# the same with -Ptests.asserts=false. The same schedule traced step by step (main, assertions on; off gives the same):
findForcedMerges by A maxSegmentCount=1 infos=[_0(1), _1(1), _2(1)] ... -> [[_1, _2]]
B calls forceMerge(2)
doMerge released segments=[_1, _2] merge.maxNumSegments=2 mergeMaxNumSegments=2
findForcedMerges by merge-thread maxSegmentCount=2 infos=[_0(1), _3(2)] ... -> null
A forceMerge(1) returned segments=[_0(1), _3(2)]
B forceMerge(2) returned segments=[_0(1), _3(2)]
RESULT forceMerge(1) returned with 2 segment(s), expected 1
# two overlapping forceMerge(1) calls instead: _0 + _3 -> _4, both return with [_4(3)]

Why it looks unintended

  • Per-call javadoc: maxNumSegments is the "maximum number of segments left in the index after merging finishes"; IndexWriter is "completely thread safe".
  • No serial order of the two calls leaves 2 segments when forceMerge(1) returns; 1 segment meets both targets.
  • Nothing documents "last call wins"; Lucene's testRandomOperations calls forceMerge with random targets from several threads.
The full argument284 words
  • The contract is per call. "Forces merge policy to merge segments until there are <= maxNumSegments" (IndexWriter.java:2188); "This call will merge those segments present in the index when the call started" (:2219); "@param maxNumSegments maximum number of segments left in the index after merging finishes" (:2223). Class javadoc: "completely thread safe, meaning multiple threads can call any of its methods, concurrently" (:167-168). In the repro no segment is added after either call starts.
  • No serial order explains it. One call after the other, in either order, leaves 1 segment when forceMerge(1) returns, and the executed 2-then-1 overlap ends with 1 for both calls.
  • Documented interference is spelled out. forceMerge says segments flushed by other threads during the call are not merged (:2219-2221); deleteAll says concurrent forceMerge callers "may receive MergePolicy.MergeAbortedExceptions" (:2743-2745). Nothing says forceMerge calls loosen each other.
  • Lucene's tests overlap forced merges. TestThreadedForceMerge calls forceMerge(1, false) and forceMerge(1) from several threads (TestThreadedForceMerge.java:92, :103), always with one target; TestIndexWriter.testRandomOperations calls writer.forceMerge(1 + random().nextInt(10), random().nextBoolean()) from several threads (TestIndexWriter.java:4290) and checks only that nothing throws.
  • A committer called a related silent shortfall "a little dangerous" (TieredMergePolicy.java:857-861, LUCENE-10093: a second forceMerge's newly flushed segments are skipped: a different defect, and a hedged question rather than a settled API reading, but it treats a second forceMerge while one runs as something users do).
  • History. The overwrite comes from LUCENE-982 (2007), which added a per-call target to a mechanism built when every optimize() targeted 1 segment; its discussion (#2058) never mentions overlapping calls, and no javadoc, comment, CHANGES.txt or MIGRATE.md entry says the last call's target wins.
The devil's-advocate check235 words

The strongest case for "works as designed", made by the cross-model reviewer, the intended-behaviour devil's advocate and the unit's first finder (who judged it not reportable): the forced-merge state is writer-wide by design, every call deliberately marks "all pending & running merges for forced merge" with its own target, so the last call wins and applications should run force merges one at a time. The thread-safety note itself goes on: "If your application requires external synchronization, you should not synchronize on the IndexWriter instance ... use your own (non-Lucene) objects instead" (IndexWriter.java:168-170). There is a precedent: in LUCENE-1335 (#2412, 2008) committers agreed that IndexWriter makes no API promises about what concurrent commit, close, rollback, optimize or addIndexes calls mean, "except that the methods all work correctly, IndexWriter won't throw an errant exception, and your index won't become corrupt", and that "higher level synchronization by the user is the right way to ensure/enforce an ordering".

Why it fails: LUCENE-1335 is about the order of different operations, not two targets, and says nothing like "last call wins"; here every serial order gives 1 segment, so the 2-segment result is not the outcome of any ordering (running the calls one at a time does avoid it: that is the workaround, not an explanation); nothing documents the behaviour and it contradicts the per-call @param; and forceMerge(1) returns normally, i.e. reports that it finished.

Suggested fix

Recommended Keep the stricter target while an earlier call is still active (+18 lines in IndexWriter.java)

While an earlier forceMerge call is still active, a new call keeps the stricter target, min(n, mergeMaxNumSegments) (+18 lines in IndexWriter.java). A later, looser call may then merge further than it asked, still within its target. Validated: the test fails stock and passes with the fix; the forced-merge and IndexWriter suites pass (211 tests, 7 skipped).

All options and trade-offs4 options · 708 words

One fix was built and validated with gradle on 645d4fe14a20; the alternatives come from the independent reproducer, the adjudication and the brief. A real fix must also decide what a later doWait=false call means for callers that are already waiting.

Keep the stricter target while an earlier call is still active (+18 lines in IndexWriter.java) (validated by the checks) (recommended)

Count the forceMerge calls that have reset the shared state and not yet returned. A new call that finds one active takes min(maxNumSegments, mergeMaxNumSegments) before the reset; the rest of forceMerge(int, boolean) moves into a helper wrapped in try/finally that decrements the counter.

synchronized (this) {
  // Overlapping forceMerge calls share segmentsToMerge and mergeMaxNumSegments: keep the
  // strictest target while another call is still waiting, so this call does not loosen it.
  if (activeForceMerges > 0) {
    maxNumSegments = Math.min(maxNumSegments, mergeMaxNumSegments);
  }
  activeForceMerges++;
  resetMergeExceptions();
  ...
}
try {
  forceMergeAndWait(maxNumSegments, doWait);
} finally {
  synchronized (this) { activeForceMerges--; }
}

Trade-offs

  • The later, looser call merges as far as the earlier call's target: forceMerge(2) overlapping a forceMerge(1) ends with 1 segment. That meets its own contract ("at most 2") and is what the reverse order already does.
  • A doWait=false call counts only while it is inside forceMerge, which it leaves right after planning, so a non-blocking forceMerge(1) followed by a blocking forceMerge(2) still loosens the background merges. No caller waits on them, so no return breaks its contract, but maintainers should decide what that should mean.
  • The waiter still does not check its own result: a policy that cannot reach n behaves as before, and segments flushed after the call started stay excluded, as documented.
  • For review (read, not run): mergeMaxNumSegments is not restored when the strictest call returns, so a third call that starts while only the looser call is active also inherits the stricter target; and activeForceMerges++ sits before the try, so an Error later in that synchronized block would leave the counter raised.
  • Merge-failure reporting is untouched: a later call's resetMergeExceptions() (:2250, :3039-3041) still clears mergeExceptions and bumps mergeGen, so a failure of a merge an earlier call registered can be dropped (the mechanism of the separate bug LFV-590).
  • Cost: one int field and two synchronized updates per call.

What the checks validated

  • Scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF: stock, the minimal test fails with assertions on and off; with the fix it passes, on and off.
  • With the fix: TestThreadedForceMerge, TestIndexWriterForceMerge, TestForceMergeForever, TestIndexWriterMerging, TestConcurrentMergeScheduler, TestIndexWriterMergePolicy, TestLogMergePolicy, TestTieredMergePolicy and TestIndexWriter (with testRandomOperations): 211 tests, 7 skipped, 0 failures.
  • Lint with the fix and the test in place: google-java-format flags only the test's long comment, not the fix; ecj and forbidden-APIs checks pass.
  • Earlier, in the hunt: the hunt's own JUnit and 19 upstream forced-merge tests pass with the fix (standalone runner).
  • Not covered: three or more overlapping calls, mixed blocking and non-blocking calls, overlaps with forceMergeDeletes, merge failures during an overlap, the full core suite.

Key the stricter target on in-flight forced merges (4 lines)

Before the reset: if (maxNumSegmentsMergesPending()) maxNumSegments = Math.min(maxNumSegments, mergeMaxNumSegments); (the independent reproducer's root-cause probe).

Trade-offs

Smallest change, and while a blocking caller waits there is always such a merge; it also tightens a later call when only a non-blocking call's merges are still running; but between a call's reset and its maybeMerge no forced merge is pending yet, so a second call that resets in that window is not tightened. Checked only with the reproducer's own 3 tests through a shadowed class, not with gradle.

Each waiter checks its own target

When no forced merge is left, a caller whose own segments are still above its n re-plans with its own n.

Trade-offs

Exact per-call semantics, but needs per-call lineage (the shared segmentsToMerge is cleared by every call) and changes the wait loop; not run.

Document the shared target

State that overlapping forceMerge calls share one target, the most recent call's maxNumSegments, and that callers who need a specific result must serialize them.

Trade-offs

No code change and matches the LUCENE-1335 stance, but leaves a normal return that does not meet the @param; not run.

Downstream note

Elasticsearch and OpenSearch serialize force merges per shard and Solr takes its commit lock before forceMerge only for hard commits (two soft-commit optimize requests with different maxSegments could overlap), so mainly applications that call IndexWriter directly from several threads can hit it (read, not run).

How it was found

Formal. A TLA+ model of how forceMerge waits for its merges, checked with TLC, violated "a normal return leaves at most n segments" for two callers with n = 1 and n = 2 in a 13-state trace, while equal targets were clean. The trace was then replayed on the stock jar as a JUnit.

The method in full179 words

Method: TLA+ model checking (TLC) of IndexWriter.forceMerge(n, doWait=true) at the granularity of the writer's synchronized blocks (the study's "formal" tier: the tool's own counterexample gave the first signal).

  • The model was set up with two callers that have different maxNumSegments, with no mechanism in mind.
  • Invariant F1: a normal return leaves the segments present at the call merged into at most n segments. Configuration two_nofail_diff (two callers, n = 1 and n = 2, no merge failures) violated F1 for the n = 1 caller in a 13-state trace; two_nofail_same (n = 1 twice) was clean at 26,698 distinct states. A liveness check with n = 1 and n = 2 (every caller eventually returns) held at 916,793 states, so it is an early return, not a hang.
  • The unit's first finder judged the trace not reportable, because the target is writer-wide by design; a second AI tool working the same unit wrote it up from the counterexample, replayed it on the stock jar as a JUnit, and the independent checks below reproduced it with their own tests.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26; 140 recorded queries over GitHub issues and PRs, all 406 open PRs' files, the JIRA archive, the dev@, java-user@ and issues@ lists, downstream repositories and the web; 25 hits assessed). Closest: LUCENE-1335 (#2412), a 2008 committer position on concurrent IndexWriter calls, not a report.

Searches3 searches

Verdict new, confidence medium-high; not fixed upstream (IndexWriter.java unchanged on upstream main 2f424fd428da, 2026-10-30). Closest items, none this bug: #2412 / LUCENE-1335 (intent, see "Why it looks unintended"); the TieredMergePolicy.findForcedMerges comment from LUCENE-10093 (a different overlapping-forceMerge defect); #3692 / LUCENE-2618 (2010, a different forced-merge race with one target; fixed); #2058 / LUCENE-982 and #1922 / LUCENE-847 (origins); #2254 / LUCENE-1177 (2008, concurrent optimize calls caused too many merges; fixed); elastic/elasticsearch#9638 (force merges serialized for resource reasons) and #102594 (a force merge to 1 ending with more segments while every request targets 1). Open PRs #16571 and #14335 touch nearby lines only.

Gaps: only public, indexed sources; the issue and PR searches were not re-run for this doc.

What was searched, when, and what turned up
WhereWhenHowFound
a scripted prior-report search: apache/lucene issues, PRs and commits, downstream repositories, dev@, java-user@, issues@, JIRA archive (LUCENE, SOLR), web2026-10-2671 queries in the follow-up run, plus the independent reproducer's and the devil's advocate's runs706, 1,048 and 966 hits; none is this bug
GitHub (manual): apache/lucene, Elasticsearch, OpenSearch, Solr; all 2,310 open issue titles; the changed files of all 406 open PRs2026-10-2636 manual queries; patches of the 23 open PRs that touch the merge files readonly the related items above; #16571 and #14335 come near the code but change neither the reset, the overwrite nor the wait loop
JIRA archive and mailing lists2026-10-269 JQL queries; 18 list queries over dev@, java-user@ and issues@LUCENE-1335 (intent), LUCENE-2618, LUCENE-1177; no report of this bug

History

Introduced by LUCENE-982 (97fbfb54273, 2007-11-30), which added optimize(int maxNumSegments) and its target overwrite; first released in 2.3.0. Executed on 21 releases from 2.3.0 to 10.6.0, main and the introducing commit: all affected. Not fixed.

Versions tested7 rows

Introduced by 97fbfb54273 "LUCENE-982: add new method optimize(int maxNumSegments) to IndexWriter" (mikemccand, 2007-11-30, Subversion r599766, no PR; issue #2058). It wrote each call's target into every pending and running merge (merge.maxNumSegmentsOptimize = maxNumSegments), re-planned the merge-finish cascade with it and left the wait loop checking only that no optimize merge is pending. Precondition: 299d6357dd2 (LUCENE-845/847/870, 2007-09-18) added the writer-wide segmentsToOptimize and the wait loop, when every call targeted 1 segment. A source build of 97fbfb54273 reproduces; its parent has no optimize(int).

Later changes kept it: LUCENE-2618 (0630ca3119f, 2010) added the writer-wide optimizeMaxNumSegments (today mergeMaxNumSegments); LUCENE-3454 (19889d68738, 2011) renamed optimize to forceMerge, which is why git blame on main points at 2011.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle and the standalone runner, assertions on and off)
10.6.0, 9.12.3 affectedthe minimal test fails (release jars, assertions on)
8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2, main a9779ef3c193 affectedthe hunt's JUnit fails on each (assertions on; on main also off)
2.3.0 to 10.5.2 (19 releases), main 2.3.0: first affecteda version-portable program: 2 segments in every bug trial (72/72), 1 in every same-target control
97fbfb54273 (introducing commit) bisectsource build: 2, 2, 2; same-target control clean
261382fd4e9 (parent), 2.2.0 last unaffectedno optimize(int): the repro does not compile on either; a same-target overlap is clean on the parent
fix on 645d4fe14a20 not affectedthe minimal test passes, assertions on and off; 211 core tests (7 skipped) pass

Independent checks

An independent reproducer, an intended-behaviour devil's advocate and an adjudication confirmed it with their own tests, including a stock-policy timed test without hooks (20/20). A reading-only review by another model family (OpenAI GPT-5.6) agreed on the root cause but gave a procedural "not real" because it could not run code. Still reproduces on main.

Checks and verdicts279 words

The GPT review did not execute code; the finder, the independent reproducer, the devil's advocate, the adjudication, the follow-up and this doc did.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (TLA+ model, then JUnit) candidate, executedTLC: F1 violated for n = 1 and 2, equal targets clean; JUnit fails on main (assertions on and off) and on 8.6.0, 8.11.4, 9.0.0, 9.12.3 and 10.5.2; its fix passes the JUnit and 19 upstream forced-merge tests.
Cross-model review, refute lens (OpenAI GPT-5.6, reading only) not real (low, procedural)Could not run code; agreed on the root cause and that the trigger is legal; argued "last call wins" (moderate). A separate re-run of the JUnit failed on main; later re-runs failed on main and 10.5.2 and showed 3 segments with forceMerge(3).
Independent reproducer realOwn public-API JUnit (a OneMerge.wrapForMerge latch, no test points) fails on main (30/30 iterations with assertions on; also with assertions off) and on 10.5.2 and 9.12.3; controls give 1 segment; a 4-line probe makes all tests pass.
Intended-behaviour devil's advocate realWeighed "last call wins" and rejected it; reach probe: LogByteSizeMergePolicy 2 vs 1, TieredMergePolicy affected only while a natural merge runs.
Adjudication confirmed, medium; class minorRe-ran the reproducer's JUnit at a new seed; own stock-policy timed test (no custom policy, test points, latches or sleeps) 20/20 with assertions on and off, control 0/20.
Current-main re-runs still reproducesMain a9779ef3c193 (and audit re-runs) and 645d4fe14a20 (the minimal test, assertions on and off, 10/10 random seeds).
This doc still reproduces; fix validatedGradle on a scratch worktree of 645d4fe14a20; the 10.6.0 and 9.12.3 jars.

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

Finding record

LFV-592, snapshot of 2026-11-06

Registry title
Two overlapping IndexWriter.forceMerge calls with different maxNumSegments share one writer-wide target, so forceMerge(1) returns normally with 2 segments when a forceMerge(2) starts on another thread while its merges are still running
Area
index / IndexWriter forced merge (writer-wide forced-merge target shared by concurrent forceMerge calls)
Priority
Low, rank 1005: A forceMerge(1) call can return with 2 segments when it overlaps a forceMerge(2) call on another thread, which is operational only and needs an unusual concurrent pattern.
Impact class
minor
Impact note
Hits an application that runs blocking forceMerge calls with different targets from two threads on one IndexWriter, when the later call has the larger maxNumSegments and starts while the first call's forced merges are still pending or running: the first call then returns normally with up to the later call's target (2 segments, or 3 with an overlapping forceMerge(3), in the repros) and no error, while no document is lost, searches are correct and CheckIndex is clean; with stock components it needs the first call to take more than one merge round (LogByteSizeMergePolicy or LogDocMergePolicy with more than mergeFactor segments, or TieredMergePolicy when a natural merge over some of the segments is already running), the default TieredMergePolicy on a static index merges in one round and is not affected, and no scale is needed.
Summary
You call forceMerge(1) on an IndexWriter and, while it is still merging, another thread calls forceMerge(2) on the same writer. You expect forceMerge(1) to return only once the index is down to 1 segment, because its javadoc defines maxNumSegments as the 'maximum number of segments left in the index after merging finishes' and IndexWriter allows concurrent calls to any method. Instead forceMerge(1) returns normally with 2 segments: both calls share one writer-wide target, the second call overwrites it (also on the first call's running merges), and the first call's wait loop only checks that no forced merge is left, never its own target.
Introduced
97fbfb542737a2625ff983feb8cb3c0af58a6805 'LUCENE-982: add new method optimize(int maxNumSegments) to IndexWriter' (Subversion r599766, by mikemccand; pre-GitHub, no PR; GitHub issue #2058), which added the per-call target, wrote it into every pending and running merge ('merge.maxNumSegmentsOptimize = maxNumSegments'), re-planned the merge-finish cascade with that overwritten value and left the wait loop checking only that no optimize merge is pending [read: git log -S in the full-history clone; exec at commit level: a source build of 97fbfb54273 reproduces 3/3 with the same-target control clean, its parent 261382fd4e9 has no optimize(int) (the repro does not compile) and a same-target overlap is clean there]. Precondition: 299d6357dd296f87a099fadd0764292b28dbde47 (LUCENE-845/847/870, 2007-09-18) made optimize() unsynchronized and added the writer-wide segmentsToOptimize and the wait loop, when every call targeted 1 segment. LUCENE-2618 (trunk 0630ca3119f, 2010) added the writer-wide optimizeMaxNumSegments (today mergeMaxNumSegments), a second place where the latest call's target wins; LUCENE-3454 (19889d68738, 2011) renamed optimize to forceMerge, which is why git blame on main points at 2011. (2007-11-30)
First affected version
2.3.0 [exec: optimize(1) overlapped by optimize(2) returned with 2 segments in 3/3 trials; the 1-then-1 control returned 1 segment in 3/3]; 2.2.0 has only a synchronized optimize() without a target [exec: the repro does not compile]
Affected versions
every release from 2.3.0 through 10.6.0 (10.6.0 [exec 2026-10-31]: the minimal test fails on the 10.6.0 release jars (SHA-1 checked against Maven Central), assertions on), including 9.12.3 (the latest 9.x), and main a9779ef3c193; never fixed. [exec, a version-portable main() repro without test points: stock LogDocMergePolicy with mergeFactor 2, ConcurrentMergeScheduler, a Directory subclass that holds one merge] 2.3.0, 2.3.2, 2.4.1, 2.9.4, 3.0.3, 3.1.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (also -da on 9.12.3, 10.5.2 and main): the first call returned with 2 segments in every bug trial (72/72 together with the introducing-commit build) and the control with 1. [exec, the candidate JUnit, whose test point exists since 8.6.0] 8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main (-ea and -da) fail with 2 segments; the independent reproduction repeated it on main, 10.5.2 and 9.12.3 with its own public-API JUnit, the intended-behaviour check on main and 10.5.2. [read] every release tag from 2.3.0 on contains the introducing commit (139 of 139) and keeps the same overwrite, cascade and wait-loop structure; IndexWriter.java is identical on the previous hunt main ea57360912ed; the forceMerge block is byte-identical on branch_10x and branch_10_6 (fetch dated 2026-10-24) and unchanged on upstream main a95c754ee003 (2026-10-26).
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
formal: TLA+/TLC
Upstream and prior reports
none found (2026-10-26, read-only; verdict new, confidence medium-high; 140 recorded queries: a scripted prior-report search over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@ and issues@ lists, the JIRA archive (LUCENE and SOLR) and the web, plus manual GitHub, JIRA and list searches, the titles of all 2,310 open apache/lucene issues and the changed files of all 406 open apache/lucene PRs (23 touch the merge files; #16571 and #14335 come near this code but change neither the reset, the overwrite nor the wait loop); the independent reproduction's (1,048 hits) and the intended-behaviour check's (966 hits) searches agree). Closest items, none the same bug: #2412 / LUCENE-1335 (2008: committers agreed that IndexWriter makes no API promises about what concurrent commit, close, rollback, optimize or addIndexes calls mean beyond working correctly, no errant exception and no corruption; a position on intent, not a report), the TieredMergePolicy.findForcedMerges comment from LUCENE-10093 (a second forceMerge's newly flushed segments are silently skipped while a forced merge runs: a different defect in the merge policy), #3692 / LUCENE-2618 (2010: a natural merge started during optimize was not marked as forced, all targets 1; fixed), #2058 / LUCENE-982 and #1922 / LUCENE-847 (origins), #2254 / LUCENE-1177 (2008: concurrent optimize calls caused too many merges; fixed), elastic/elasticsearch#9638 (force merges serialized for resource reasons) and elastic/elasticsearch#102594 (a force merge to 1 ending with more than 1 segment while every request targets 1; its maintainers point to relocation, refreshes and indexing). Not fixed on upstream main a95c754ee003 or branch_10x (checked 2026-10-26).
Lucene commit tested
main 645d4fe14a20 (gradle in a scratch worktree and the standalone runner, assertions on and off); the minimal test on the 10.6.0 and 9.12.3 jars (fails); the hunt's JUnit on 8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main a9779ef3c193; a version-portable program on 19 releases from 2.3.0 to 10.5.2, main and a source build of the introducing commit; the recommended fix in a scratch worktree of 645d4fe14a20. IndexWriter.java is identical at a9779ef3c193, 645d4fe14a20 and upstream main 2f424fd428da (2026-10-30).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:167-168 (at a9779ef3c193) (class javadoc 'completely thread safe, meaning multiple threads can call any of its methods, concurrently'), :378-379 (one segmentsToMerge map, one mergeMaxNumSegments), :2188 ('Forces merge policy to merge segments until there are <= maxNumSegments'), :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'), :2237-2279 (forceMerge(int, boolean): segmentsToMerge.clear() :2251, mergeMaxNumSegments = maxNumSegments :2256, merge.maxNumSegments = maxNumSegments on pending :2261 and running :2271 merges), :2284-2318 (wait loop; maxNumSegmentsMergesPending() :2305), :2326-2336 (maxNumSegmentsMergesPending checks for any forced merge), :2489 (new forced merges take the caller's target), :5105 (MERGE_FINISHED cascade with merge.maxNumSegments), :5200 (registerMerge gives later merges mergeMaxNumSegments)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:167-168, (at 645d4fe14a20) 377-379, 2188, 2219-2223, 2237, 2249-2281, 2284-2312, 2326-2336, 2489, 5020-5024, 5105, 5199-5200
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:167-168, (at 645d4fe14a20) 2188, 2219-2223, 2258-2259, 2743-2745
  • lucene/core/src/test/org/apache/lucene/index/TestThreadedForceMerge.java:92, (at 645d4fe14a20) 103; TestIndexWriter.java:4247, 4290; lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:856-863
Prior reports
  • a Lucene clone git diff 645d4fe14a20 origin/main (2f424fd428da) -- IndexWriter.java, LogMergePolicy.java: empty

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

Repro files

About the diagrams

Made separately from this text, from the code at 645d4fe14a20 and probe runs; the PNG is the summary still.

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.