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.
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.- A policy may need several rounds.
LogMergePolicymerges at most mergeFactor segments at a time; when a forced merge finishes,IndexWriterasks the policy again with that merge's target (a cascade). - The waiting call sleeps in a loop and leaves it when no forced merge is pending or running.
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.- Three one-document segments,
_0,_1and_2, withLogDocMergePolicyat mergeFactor 2:forceMerge(1)needs two rounds,_1 + _2 -> _3, then_0 + _3 -> _4. - Thread A calls
forceMerge(1). While its first merge runs, thread B callsforceMerge(2)on the same writer. - You expect A to return with 1 segment:
maxNumSegmentsis the "maximum number of segments left in the index after merging finishes",IndexWriterallows concurrent calls, and 1 segment also meets B's target. - 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. - Nothing is lost, searches are correct and a later
forceMerge(1)finishes the job. The reverse overlap (2 first, then 1) and two overlappingforceMerge(1)calls both end with 1 segment.
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.
- One forced-merge state per writer.
segmentsToMerge("used by forceMerge to note those needing merging") andmergeMaxNumSegmentsare plain fields of the writer (:377-379). - Each call overwrites it.
forceMerge(int, boolean)(:2237), under the writer's lock (:2249), clears and refillssegmentsToMerge(:2251-2255), setsmergeMaxNumSegments = maxNumSegments(:2256) and then, under the comment "Now mark all pending & running merges for forced merge" (:2258-2259), setsmerge.maxNumSegments = maxNumSegmentson every pending (:2261) and running (:2271) merge. The merge that an earlier, still-waitingforceMerge(1)started now carries the target 2. - New merges take the latest target.
maybeMerge(..., MergeTrigger.EXPLICIT, maxNumSegments)(:2281) asksfindForcedMergeswith this call's target and stamps each returned merge with it (:2489); a merge registered later over a segment insegmentsToMergegetsmergeMaxNumSegments(:5199-5200). - The cascade uses the overwritten target. A committed forced merge adds its new segment to
segmentsToMerge(:5020-5024), and the merge thread re-plans withupdatePendingMerges(mergePolicy, MergeTrigger.MERGE_FINISHED, merge.maxNumSegments)(:5105). With target 2 and segments_0(1),_3(2)left,LogMergePolicyreturns no merge. - The waiter checks "any forced merge left", not its own target. The wait loop (
:2284-2312) waits whilemaxNumSegmentsMergesPending()(:2305) is true; that method (:2326-2336) only asks whether a pending or running merge hasmaxNumSegmentsset. Once the last forced merge is done,forceMerge(1)returns normally. Nothing compares the segment count with itsn.
| scenario | forceMerge(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) calls | 1 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 segments | 1 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
forceMergecalls 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:
LogByteSizeMergePolicyorLogDocMergePolicy(stock, not the default) with more than mergeFactor segments; with the defaultTieredMergePolicyit was reproduced only whenforceMerge(1)starts while a natural merge over some segments runs (executed withfloorSegmentMBlowered so tiny test segments behave like real ones). AforceMerge(1)withTieredMergePolicyon a static index of small segments merges in one round and was not affected; largerTieredMergePolicyindexes 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,CheckIndexwas clean, and a secondforceMerge(1)reached 1 segment with no deletions. - Likelihood: rare.
forceMergeis 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).
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.
/*
* 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);
}
}
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.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): affected; the same test also fails on the 10.6.0 and 9.12.3 jars |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux, aarch64 |
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)]- Per-call javadoc:
maxNumSegmentsis the "maximum number of segments left in the index after merging finishes";IndexWriteris "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
testRandomOperationscallsforceMergewith 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.
forceMergesays segments flushed by other threads during the call are not merged (:2219-2221);deleteAllsays concurrentforceMergecallers "may receiveMergePolicy.MergeAbortedExceptions" (:2743-2745). Nothing saysforceMergecalls loosen each other. - Lucene's tests overlap forced merges.
TestThreadedForceMergecallsforceMerge(1, false)andforceMerge(1)from several threads (TestThreadedForceMerge.java:92,:103), always with one target;TestIndexWriter.testRandomOperationscallswriter.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 secondforceMergewhile 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.txtorMIGRATE.mdentry 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.
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 aforceMerge(1)ends with 1 segment. That meets its own contract ("at most 2") and is what the reverse order already does. - A
doWait=falsecall counts only while it is insideforceMerge, which it leaves right after planning, so a non-blockingforceMerge(1)followed by a blockingforceMerge(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
nbehaves as before, and segments flushed after the call started stay excluded, as documented. - For review (read, not run):
mergeMaxNumSegmentsis 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; andactiveForceMerges++sits before thetry, so anErrorlater in thatsynchronizedblock would leave the counter raised. - Merge-failure reporting is untouched: a later call's
resetMergeExceptions()(:2250,:3039-3041) still clearsmergeExceptionsand bumpsmergeGen, so a failure of a merge an earlier call registered can be dropped (the mechanism of the separate bug LFV-590). - Cost: one
intfield and two synchronized updates per call.
What the checks validated
- Scratch worktree of
645d4fe14a20, OpenJDK 25.0.3, seedDEADBEEF: 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,TestTieredMergePolicyandTestIndexWriter(withtestRandomOperations): 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.
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.
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.
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.
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).
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| a scripted prior-report search: apache/lucene issues, PRs and commits, downstream repositories, dev@, java-user@, issues@, JIRA archive (LUCENE, SOLR), web | 2026-10-26 | 71 queries in the follow-up run, plus the independent reproducer's and the devil's advocate's runs | 706, 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 PRs | 2026-10-26 | 36 manual queries; patches of the 23 open PRs that touch the merge files read | only 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 lists | 2026-10-26 | 9 JQL queries; 18 list queries over dev@, java-user@ and issues@ | LUCENE-1335 (intent), LUCENE-2618, LUCENE-1177; no report of this bug |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails (gradle and the standalone runner, assertions on and off) |
| 10.6.0, 9.12.3 | affected | the minimal test fails (release jars, assertions on) |
8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2, main a9779ef3c193 | affected | the 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 affected | a version-portable program: 2 segments in every bug trial (72/72), 1 in every same-target control |
97fbfb54273 (introducing commit) | bisect | source build: 2, 2, 2; same-target control clean |
261382fd4e9 (parent), 2.2.0 | last unaffected | no optimize(int): the repro does not compile on either; a same-target overlap is clean on the parent |
fix on 645d4fe14a20 | not affected | the minimal test passes, assertions on and off; 211 core tests (7 skipped) pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (TLA+ model, then JUnit) | candidate, executed | TLC: 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 | real | Own 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 | real | Weighed "last call wins" and rejected it; reach probe: LogByteSizeMergePolicy 2 vs 1, TieredMergePolicy affected only while a natural merge runs. |
| Adjudication | confirmed, medium; class minor | Re-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 reproduces | Main a9779ef3c193 (and audit re-runs) and 645d4fe14a20 (the minimal test, assertions on and off, 10/10 random seeds). |
| This doc | still reproduces; fix validated | Gradle on a scratch worktree of 645d4fe14a20; the 10.6.0 and 9.12.3 jars. |
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.
- PNG 580 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,374 words
- 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 maina9779ef3c193; 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 of645d4fe14a20.IndexWriter.javais identical ata9779ef3c193,645d4fe14a20and upstream main2f424fd428da(2026-10-30).
- 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-2745lucene/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
- TestOverlappingForceMerge.java (one test):
repro/LFV-592/src/org/apache/lucene/index/TestOverlappingForceMerge.java(97 lines)
Made separately from this text, from the code at 645d4fe14a20 and probe runs; the PNG is the summary still.
assets/LFV-592/bug.png(580 KB)
