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.
- Use the default
ConcurrentMergeSchedulerand a merge policy that merges 2 segments (LogDocMergePolicy, merge factor 2). Add document 0 and commit; add document 1 without committing. - 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. - 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. - Instead it returns normally with 2 segments: in the deterministic test every time, and without test points or
InfoStreamhooks in 22 to 53 of 500 runs. An instant later the writer closes itself and rolls back document 1. close()then returns normally too, and the index holds 1 of 2 documents. Nothing is corrupted. With the defaultTieredMergePolicyit is the same: forceMerge(1) returned with 9 segments.
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.
- 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.
- forceMerge flushes first (
:2248). The flush can register a natural merge, stamped with the current generation, say 0 (merge.mergeGen = mergeGen,:5218). - 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 updatemerge.mergeGen: the adopted merge keeps stamp 0. - The adopted merge fails.
handleMergeExceptioncallsaddMergeException(:5040-5041), which records it onlyif (... mergeGen == merge.mergeGen)(:5821): 1 is not 0, so the failure is dropped. - 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"). - 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), passesensureOpen()because the writer is not closed yet (:2318) and returns normally. - 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 byensureOpen()) throwsAlreadyClosedException;close()androllback()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).
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)orforceMerge(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 loneforceMerge(1)after uncommitted adds is enough. A writer that only opens an index and callsforceMerge(1)with no uncommitted changes, like the upgrade idiom inlucene/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
ConcurrentMergeSchedulerandTieredMergePolicygive 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 usedLogDocMergePolicywith merge factor 2. Not affected:SerialMergeScheduler(the merge runs in the calling thread, so its exception propagates) andforceMerge(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 throwsAlreadyClosedExceptionwith the tragedy as cause andgetTragicException()returns it, butclose()returns normally, sochange; 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) orclose()committed the change first (0 to 2). The default scheduler also throwsMergeExceptionin 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/forceMergereturned 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.
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.
/*
* 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);
}
}
}
# 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.
More files, environment, full output53 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- 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", thenensureOpen()(: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 exceptionon 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;addMergeExceptionreads 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.txtorMIGRATE.mdentry, test or issue describes a normal return after a failed adopted merge;TestIndexWriterExceptions.testForceMergeExceptionsandTestIndexWriterOnDiskFulltolerate 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.
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.
+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
fixtrag14,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, theIOException: background merge hit exceptionit gets today; the control shows this change. As today, anInfoStreamwhosemessage()throws makesonTragicEventskip setting the field (:6075-6083). onTragicEventruns twice per failed merge (the secondcompareAndSetis 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 inabortMergesuntil 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 throwsIllegalStateException: ... cannot complete forceMerge; pass 4), and an earlier version of the tests withtests.iters=10(seed CAFEBABE): 50 of 50. - 24 closest
org.apache.lucene.indexsuites (TestIndexWriter,TestIndexWriterExceptions,TestIndexWriterExceptions2,TestIndexWriterOnDiskFull,TestIndexWriterForceMerge,TestConcurrentMergeScheduler,TestTragicIndexWriterDeadlock,TestThreadedForceMerge,TestRollback,TestAddIndexesand 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 testpasses 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.
+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 exceptionwhen the waiter is first,IllegalStateExceptionwhen 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 whosegetException()is already set to the failure list. - Keeps the writer-wide
mergeGenand 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 configtwo_stock_sameshows 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 throwsIOException: background merge hit exception; pass 4), and an earlier version of the tests withtests.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 testpasses 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.
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.
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.
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
mergeGenfix 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 modelclose(),commit()or a throwingInfoStream: 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,
MockDirectoryWrapperfault injection, anInfoStreamhold). A stress test without test points orInfoStreamhooks 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), downstream repositories | 2026-10-26 | a 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 port | no report of this bug; every query on the internal identifiers returned 0 results |
| Open apache/lucene PRs | 2026-10-26 | changed 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 archive | 2026-10-26 | 16 tool queries and 18 hand-written JQL queries; 30 issues read in full | LUCENE-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-26 | 125 queries; 12 threads read in full | only threads where optimize/forceMerge did throw "background merge hit exception" |
| Web | 2026-10-26 | 7 queries | elastic/elasticsearch#102594 (a force merge ends with more than one segment, no merge failure reported; symptom level only) |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193, ea57360912ed | affected | JUnit 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 | affected | JUnit 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 | affected | JUnit 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 | affected | history harness with the hold: returns normally, then the tragedy closes the writer |
| 5.3.0 | race | returns normally, then the tragedy closes the writer; 6/10 without the hold |
| 2.3.0, 5.2.1 | deterministic | returns normally with 2 segments every time, writer stays open; control throws "background merge hit exception" |
| 2.4.0 to 4.10.4 | by reading | the 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 affected | optimize() exists, but there are no background merges; the harness does not compile |
| main + either fix sketch | not affected | forceMerge throws in every test; 0 normal returns hook-free |
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).
| Check | Verdict | What it found |
|---|---|---|
| Independent reproducer | real | Own 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, procedural | Could 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, medium | Re-runs fail on main (-ea, -da) and 10.5.2; hook-free 1/200 and 9/200. |
| Intended-behaviour devil's advocate | real, medium-high | No 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, high | Re-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 | keep | Seed A0D17004: 4 run / 3 fail, -ea and -da; hook-free 4/300; impact not inflated. |
| This doc's gradle run | reproduces; both fixes pass | Stock: 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 fixed | An 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. |
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 506 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,499 words
- 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 plainjavac/javaand 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 mainea57360912ed; the exact gradle commands of the repro section on a fresh stock worktree; both fix sketches in scratch worktrees ofa9779ef3c193(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).
- 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-6106lucene/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
- TestForceMergeAdoptedMergeFailure.java (JUnit test):
repro/LFV-590/src/org/apache/lucene/index/TestForceMergeAdoptedMergeFailure.java(115 lines)
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).
assets/LFV-590/bug.png(506 KB)
