forceMergeDeletes(false) returns at once with a MergeObserver, and await() is how you learn whether the background merges worked. It only waits for them to stop: a failed or aborted merge still yields true.
IndexWriter.forceMergeDeletes()rewrites segments that hold many deleted documents, so those documents are really dropped. The merge policy picks the segments;TieredMergePolicytakes a segment over 10% deleted by default.- Since 10.4.0,
forceMergeDeletes(false)does not wait: with the defaultConcurrentMergeSchedulerthe merges run on background threads, and the call returns an experimentalMergeObserverwithnumMerges(),numCompletedMerges(),await(),await(timeout, unit)andawaitAsync(). - A merge can fail (an I/O error, such as a full disk; the writer then closes itself) or be aborted (
rollback(),deleteAll()). Either way its new segment is never committed.
IOException and completes the merge's future with false; await() waits for the future, never reads the value and returns true. Select the still to open it at full size. Generated with AI, checked against the code.- Use the default
IndexWriterConfigwith aConcurrentMergeSchedulerand aTieredMergePolicywithsetForceMergeDeletesPctAllowed(0). Add 10 documents and delete 1. TheDirectorythrows anIOExceptionwhenever a merge writes a file, as a full disk would. - Call
forceMergeDeletes(false). It schedules 1 merge on a background thread and returns aMergeObserverat once. Callobserver.await(). - You expect
false: the javadoc saysawait()returnstrue"if all merges completed successfully" andfalse"on error". - Instead it returns
true, while the writer records the merge'sIOExceptionas its tragic exception. The failed merge completes its future normally with a success flag offalse;await()only waits for the futures and never reads the flag. - With no fault at all,
rollback()ordeleteAll()aborting the running merge also givestrue; afterdeleteAll()the writer stays open with no tragic exception.
Applications on 10.4.0 or later that call the non-blocking forceMergeDeletes(false) and trust await()'s result, when that merge fails (for example a full disk) or is aborted by their own rollback() or deleteAll(). The default component types suffice once a segment is more than 10% deleted. Nothing in the index goes wrong, and the blocking forceMergeDeletes(true) throws on failure.
Mechanism, terms and numbers688 words
IndexWriter.forceMergeDeletes(false) (since 10.4.0) hands the forced-deletes merges to the merge scheduler without waiting (with the default ConcurrentMergeScheduler they run on background threads) and returns a MergePolicy.MergeObserver (@lucene.experimental). Its await() is documented to return true if all merges completed successfully and false on error. It returns true as soon as the merges stop, whatever their outcome: each merge's CompletableFuture<Boolean> is completed normally with the success flag as its value, and await() waits for the futures without reading the values; its false branches catch exceptions that a failed merge never produces. So after a merge that failed with an I/O error (the writer then closes itself) or was aborted by rollback() or deleteAll() (no tragic exception; after deleteAll() the writer stays open), the caller is told the merges succeeded, and the observer itself has no other way to report the outcome. Nothing in the index goes wrong, the blocking forceMergeDeletes(true) throws on failure, and no Lucene code calls the observer: a minor, deterministic contract bug in a young, experimental API, in 10.4.0 to 10.6.0 and main. A three-line check of hasCompletedSuccessfully(), the one addIndexes(CodecReader...) already makes, fixes it and passes the whole core test suite; a javadoc correction plus a public success query is the alternative.
// MergePolicy.java:285-290 OneMerge.close, called once per merge, success or not
final void close(boolean success, boolean segmentDropped, IOConsumer<MergeReader> readerConsumer)
throws IOException {
if (mergeCompleted.complete(success) == false) { // :289 completes NORMALLY, value true or false
...
// MergePolicy.java:533-544 MergeSpecification.await() (package-private)
boolean await() {
try {
CompletableFuture<Void> future = getMergeCompletedFutures(); // :528-531 allOf(every mergeCompleted)
future.get();
return true; // :538 every merge finished, any outcome
} catch (InterruptedException e) {
throw new ThreadInterruptedException(e);
} catch (ExecutionException | CancellationException _) {
return false; // :541-542 never reached by a failed merge
}
}
// MergePolicy.java:908-917 MergeObserver.await() (public)
* @return {@code true} if all merges completed successfully or no merges were needed, {@code
* false} on error
public boolean await() {
return spec == null || spec.await();
}forceMergeDeletes(false)(IndexWriter.java:2345-2405) flushes one segment (10 documents, 1 deleted), asks the merge policy for forced-deletes merges (1), registers it, hands it to the merge scheduler and returnsnew MergePolicy.MergeObserver(spec); withdoWait == falseit skips the loop that checks for failures (:2369-2397).- The merge fails on the merge thread: the
Directorythrows when the merge creates its first file. The exception is thrown insidemergeMiddle'stry(IndexWriter.java:5482-5483), andmergeMiddle's ownfinallycallscloseMergeReaders(merge, true, false)(:5808-5813), which callsmerge.close(false, ...)(:5394-5402):mergeCompleted.complete(false), before the exception reachesIndexWriter.merge(:5065-5114). TherehandleMergeExceptionrethrows it; the secondcloseMergeReadersinmerge'sfinally(:5092) is a no-op (hasFinished(),:5397), and only after thatfinallydoestragicEvent(t, "merge")record the failure (:5112). A probe traced the completing call:closeMergeReaders:5400 <- mergeMiddle:5812 <- merge:5081. await()returnstrue.allOf(...).get()returns normally once the future is complete; theBooleanis never read. Nothing inorg.apache.lucene.indexcompletesmergeCompletedexceptionally or cancels it (it isprivate final; grep finds nocompleteExceptionallyorcancel), so thefalsebranches are reachable only by a timeout inawait(timeout).- An abort ends the same way.
rollback()anddeleteAll()callabortMerges()(IndexWriter.java:2621,:2771,:2821-2861): a pending merge goes throughabortOneMerge(:5133-5136,closeMergeReaders(merge, true, false)), a running one stops with aMergeAbortedExceptionthathandleMergeExceptionswallows (:5043-5053), thenmerge'sfinallycompletes the future (:5092; probe:closeMergeReaders:5400 <- merge:5092). No tragic event. - The outcome is recorded but not public.
OneMerge.hasCompletedSuccessfully()(MergePolicy.java:452-459) returns the flag, andaddIndexes(CodecReader...)checks it right after its ownspec.await()(IndexWriter.java:3313-3315). The observer'snumCompletedMerges()counts failed merges as completed (isDone(),:901) andawaitAsync()completes normally, so nothing on the observer tells success from failure. Outside it the routes are indirect:IndexWriter.getTragicException()reports a tragic failure but not an abort, and a custom merge policy can wrap its merges and override the public hookOneMerge.mergeFinished(success, segmentDropped).
Exposure and likelihood369 words
- Who is hit. Applications that call
forceMergeDeletes(false)(since 10.4.0;MergeObserveris@lucene.experimental) and useawait()orawait(timeout, unit)as a success signal, when a forced-deletes merge fails (an I/O error such as a full disk) or is aborted by their ownrollback()ordeleteAll()while it runs. The default component types (IndexWriterConfig,ConcurrentMergeScheduler,TieredMergePolicy) suffice once a segment's deleted share is above the policy's threshold (more than 10% by default); the repro sets the threshold to 0 so that 1 delete in 10 documents qualifies. No scale and no timing are needed for the I/O path; assertions on or off. - What goes wrong. A wrong
truefrom a method whose only job is to report the outcome: the caller believes the deleted documents were merged away although the merge never finished. The observer itself offers no other way to learn the outcome; the routes outside it are indirect. - How much it hides. An I/O failure is tragic: the writer closes itself, and later calls throw
AlreadyClosedException, so the failure surfaces soon. But the future completes before the writer records the tragedy: in a probe that deleted without committing, then calledawait()andcommit(),commit()still succeeded in 3 of 30 iterations (and threw in 27). That depends on timing, so it shows only that the writer can still accept a commit right afterawait()returns. An abort records no tragic exception, and afterdeleteAll()the writer stays open, soawait()'strueis all the observer reports. - What does not go wrong. The failed merge is never committed; committed deletes stay hidden after a failed or aborted merge (probe: 0 of 30 deleted ids visible after reopening). Uncommitted deletes are lost only when the writer closes itself after the tragic failure and rolls back to the last commit, and the application then sees the exception from
commit(). No Lucene code callsMergeObserver.await()(only the test framework'sRandomIndexWriterpasses the observer through; luke calls the blocking form). - Downstream. A read-only public code search (not exhaustive) found Elasticsearch, OpenSearch and Solr calling the blocking
forceMergeDeletes. - Impact class: minor (a wrong return value of an experimental API with no in-tree caller); priority low.
One JUnit test on the public API: a FilterDirectory that throws IOException for merge output, 10 documents with 1 delete, forceMergeDeletes(false), then await(). Two preconditions (1 merge scheduled; the writer recorded the failure) stop it failing for the wrong reason. Deterministic: one background merge thread, no sleeps. Put it in lucene/core/src/test/org/apache/lucene/index/ and run it with gradle.
package org.apache.lucene.index;
import java.io.IOException;
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.Directory;
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;
public class TestMergeObserverAwaitOnFailure extends LuceneTestCase {
public void testAwaitAfterFailedMerge() throws Exception {
Directory dir =
new FilterDirectory(new ByteBuffersDirectory()) {
@Override
public IndexOutput createOutput(String name, IOContext context) throws IOException {
if (context.context() == IOContext.Context.MERGE) {
throw new IOException("simulated disk full");
}
return super.createOutput(name, context);
}
};
ConcurrentMergeScheduler cms =
new ConcurrentMergeScheduler() {
@Override
protected void handleMergeException(Throwable exc) {}
};
TieredMergePolicy mp = new TieredMergePolicy();
mp.setForceMergeDeletesPctAllowed(0);
IndexWriter w =
new IndexWriter(dir, new IndexWriterConfig().setMergeScheduler(cms).setMergePolicy(mp));
for (int i = 0; i < 10; i++) {
Document doc = new Document();
doc.add(new StringField("id", Integer.toString(i), Field.Store.NO));
w.addDocument(doc);
}
w.deleteDocuments(new Term("id", "0"));
MergePolicy.MergeObserver observer = w.forceMergeDeletes(false);
assertEquals("forced-deletes merges scheduled", 1, observer.numMerges());
boolean success = observer.await();
cms.sync();
assertNotNull("the forced-deletes merge must have failed", w.getTragicException());
w.close();
dir.close();
// MergeObserver#await() javadoc: "true if all merges completed successfully ..., false on error"
assertFalse("expected await() == false after the failed merge, got true", success);
}
}
# $REPRO holds TestMergeObserverAwaitOnFailure.java; $LUCENE is a Lucene checkout
cp $REPRO/TestMergeObserverAwaitOnFailure.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE
./gradlew :lucene:core:test --tests TestMergeObserverAwaitOnFailure -Ptests.seed=DEADBEEF
- Expected
await()returnsfalseafter the failed merge, as its javadoc says ("falseon error").- Observed
await()returnstrue:expected await() == false after the failed merge, got true, whilegetTragicException()holds the merge'sIOException.
More files, environment, full output352 words
Why each piece is there (from minimization): handleMergeException is overridden empty, otherwise the merge thread's exception escapes and the test framework fails the test for an uncaught exception, not for the claim; cms.sync() is only for the precondition, because the future completes before the writer records the tragic exception; with 2 documents and 1 delete a natural merge took the segment first, numMerges() was 0 and await() rightly returned true, so the test uses 10 documents with 1 delete and setForceMergeDeletesPctAllowed(0). Only the untimed await() is asserted, because its @return is unambiguous.
Nothing uses random(); the seed sets only the test framework's randomization (codec, locale, timezone and similar), and seeds DEADBEEF and 5EED1234 (10 iterations) fail the same way, assertions on and off. The same failure, by the repro harness: main 645d4fe14a20 and the 10.6.0 jars, assertions on and off. Use this one test as the regression test too. The file has no ASF license header, and ./gradlew tidy rewraps one 101-character comment.
The abort path (rollback() or deleteAll() while the merge runs, no fault injection) has the same root cause; it was run by the independent reproducer (await(timeout) asserted, rollback()) and the devil's advocate (both awaits, deleteAll()) with their own tests, and the fact check ran the untimed await() after rollback() too (true) and needs a merge held running until it is aborted, so it is not part of the minimal repro.
| lucene | main 645d4fe14a20 and 10.6.0: reproduced, assertions on and off; 10.4.0 to 10.5.2: reproduced by the hunt's test; 10.3.2 and 9.12.3: no MergeObserver |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 |
# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on
TestMergeObserverAwaitOnFailure > testAwaitAfterFailedMerge FAILED
java.lang.AssertionError: expected await() == false after the failed merge, got true
at org.apache.lucene.index.TestMergeObserverAwaitOnFailure.testAwaitAfterFailedMerge(TestMergeObserverAwaitOnFailure.java:50)
1 test completed, 1 failed
# repro harness, the same message: main -ea, main -da, 10.6.0 -ea, 10.6.0 -da (1 run, 1 failed each)
# both preconditions (lines 43 and 46) pass first; line 50 is the final assertFalse
# with fix A: the test passes (gradle; harness -ea and -da)- The javadoc is explicit:
await()returnstrue"if all merges completed successfully",false"on error" (MergePolicy.java:912-913). - That
falsecan never happen: the futures always complete normally, andawait()returnsfalseonly on exceptions no merge produces. - The observer itself has no other way to report the outcome; only package-private code reads the success flag.
The full argument230 words
- Contract.
MergePolicy.java:912-913,await(): "@returntrueif all merges completed successfully or no merges were needed,falseon error".:925-926,await(timeout, unit): "@returntrueif all merges completed within timeout or no merges were needed,falseon timeout or error". - The documented
falseis unreachable. The onlyfalsebranches catchExecutionExceptionandCancellationException(untimed) orExecutionExceptionandTimeoutException(timed); nothing completes the futures exceptionally or cancels them, and an interrupt throws instead of returningfalse. A contract with a dead branch is a mismatch, whichever side is changed. - Aborts are meant to be visible. The
deleteAll()javadoc: "this method will forcefully abort all merges in progress. If other threads are runningforceMerge,addIndexes(CodecReader[])orforceMergeDeletesmethods, they may receiveMergePolicy.MergeAbortedExceptions" (IndexWriter.java:2743-2745). - History: an oversight when the API went public. #15378 (2025-11-02) wrapped the package-private
MergeSpecification.awaitmethods unchanged and wrote the two@returnlines;git blamegives every line of bothawaitmethods and their javadoc to that change. Its review (23 inline comments, 16 reviews) covers naming,numCompletedMergesandtoString, never failure semantics; its tests cover a successful merge, no merges and a timeout, never a failed or aborted merge. - Nothing found says otherwise. Our searches (see Prior reports, with their limits) found no test, review comment, issue, CHANGES/MIGRATE text or list thread saying that
await()returnstrueafter a failure.
The devil's-advocate check229 words
Strongest case for "intended" (the cross-model reviews rated it strong; a later cross-model fact check rated it moderate): await() means completion, by design. The package-private await(timeout) (LUCENE-8962, 2020) and await() (LUCENE-10216, 2022) were always completion barriers, with success as the separate query hasCompletedSuccessfully(), which addIndexes checks separately (IndexWriter.java:3313-3315). Everything around the @return lines speaks of completion: the class javadoc ("Provides methods to query merge status and wait for completion"), forceMergeDeletes' @return ("to monitor merge progress and wait for completion"), awaitAsync() and the CHANGES entry. The timed @return contradicts itself for a failed merge. An I/O failure is tragic, so the writer reports it anyway. And the API is @lucene.experimental.
Why it fails: it explains the code, not the public contract. The untimed @return says "completed successfully" and "false on error" without ambiguity, and that false can never happen; the observer itself gives callers no other way to learn the outcome (hasCompletedSuccessfully() is package-private, numCompletedMerges() counts failed merges, awaitAsync() completes normally), and the routes outside it are indirect (getTragicException() misses aborts; a custom merge policy must wrap its merges); and the abort path is not tragic, so nothing else reports it. The argument does decide the shape of the fix: a javadoc correction is a legitimate answer, and both are listed under Suggested fix. Verdict: a real contract mismatch (adjudication: confirmed, high).
Recommended A. Make MergeObserver.await() read the success flag (three lines)
In MergeObserver.await() and await(timeout, unit), after the wait also require every merge's hasCompletedSuccessfully(), the check addIndexes(CodecReader...) already makes. The timed @return gains "successfully". The repro passes, as do the 9 closest suites (172 tests with the repro, assertions on and off) and all 8,898 core tests.
public boolean await() {
return spec == null || (spec.await() && allMergesSucceeded());
}
public boolean await(long timeout, TimeUnit unit) {
return spec == null || (spec.await(timeout, unit) && allMergesSucceeded());
}
private boolean allMergesSucceeded() {
return spec.merges.stream().allMatch(m -> m.hasCompletedSuccessfully().orElse(false));
}All options and trade-offs3 options ยท 564 words
One code fix was run; A is recommended, B is the javadoc route maintainers may prefer. Workaround for users today: call the blocking forceMergeDeletes(true), which throws on a failed merge, or check IndexWriter.getTragicException() after ConcurrentMergeScheduler.sync() (it does not cover aborts).
A. Make MergeObserver.await() read the success flag (three lines) (validated by the checks) (recommended)
After the wait, require hasCompletedSuccessfully() of every merge, as addIndexes(CodecReader...) does (IndexWriter.java:3314-3315). The check runs only once every merge has finished, so orElse(false) is never taken. The package-private MergeSpecification.await and its addIndexes caller are untouched. A merge whose segment was dropped because all its documents were deleted still counts as a success (close(true, true, ...) from commitMerge). The diagrams show this same fix A, run in the diagram probe. The full diff also changes one word in the timed @return.
public boolean await() {
return spec == null || (spec.await() && allMergesSucceeded());
}
public boolean await(long timeout, TimeUnit unit) {
return spec == null || (spec.await(timeout, unit) && allMergesSucceeded());
}
private boolean allMergesSucceeded() {
return spec.merges.stream().allMatch(m -> m.hasCompletedSuccessfully().orElse(false));
}Trade-offs
A behaviour change in an experimental API: a caller that used await() as a pure barrier now gets false after a failure or abort, which is what the javadoc says. In the timed variant false now means timeout or failure; checking numCompletedMerges() == numMerges() afterwards hints at which, but not atomically (the last merge may finish in between). Aborts the caller started itself (rollback(), deleteAll()) now also give false; whether those count as an "error" is an API-design call for the maintainers. awaitAsync() still completes normally after a failure; its javadoc promises only completion, so leave it or add a sentence saying so.
What the checks validated
- Scratch worktree of main
645d4fe14a20(removed afterwards), seed DEADBEEF: stock, the test fails through gradle; with A it passes. - The repro and the 9 closest suites with A,
-Ptests.asserts=trueandfalse:TestMergeObserverAwaitOnFailure,TestIndexWriterMerging(the upstreamMergeObservertests),TestMergePolicy,TestAddIndexes,TestConcurrentMergeScheduler,TestIndexWriterForceMerge,TestTieredMergePolicy,TestIndexWriterExceptions,TestIndexWriterOnDiskFull,TestIndexWriterDelete: 172 tests, 11 skipped, 0 failures each time. - All of
:lucene:core:testwith A (assertions on): 8,898 tests in 749 classes, 322 skipped, 0 failures. - With A shadowed in the repro harness: the repro passes (assertions on and off), and so do the devil's advocate's 2 tests (I/O failure,
deleteAll()abort) and the independent reproducer's 3 (I/O failure,rollback()abort, successful-merge control). checkGoogleJavaFormatreports no violation inMergePolicy.java.
Say that both methods report completion ("true once all merges have finished, successfully or not, or no merges were needed; false only on timeout"), and add a public query such as boolean allMergesSucceeded() (A's helper, made public).
Trade-offs
No behaviour change. A javadoc change without the query leaves the non-blocking API with no way to report a failed or aborted merge. Not run.
Return false there when a merge completed with success == false.
Trade-offs
The same effect for the observer only if both package-private await variants change; their other callers ignore the return value (addIndexes(CodecReader...), which checks success itself, and the merge-on-commit and merge-on-getReader waits, IndexWriter.java:739, :3894), but it changes the completion-only internal layer from 2020/2022 for no gain over A. Not run.
Downstream, a read-only public code search (not exhaustive) found Elasticsearch, OpenSearch and Solr calling the blocking forceMergeDeletes, which throws on a failed merge, so their force-merge paths are not affected.
Semi-formal: while writing a TLA+ model of the merge scheduler and IndexWriter merge lifecycle, the finder read how merges complete and saw that the observer's "false on error" could not be reached. The model has no MergeObserver, so the model checker gave no trace; a public-API JUnit with an injected merge-time IOException witnessed it.
The method in full121 words
- Method (semi-formal). The hunt unit modelled the
ConcurrentMergeScheduler/IndexWritermerge lifecycle in TLA+, with a merge-completion property (every merge ends closed). Writing that property meant reading how merges complete, and the finder saw thatOneMerge.closecompletes the future normally with a success flag that the publicawait()never reads. - Witness. The model does not represent
await's return value, so the model checker produced no trace. The finder wrote a public-API JUnit with aFilterDirectorythat fails merge output; it failed on main with assertions on and off, and later was minimized to the one test above. - The finder labelled it formal; the registry records semi-formal, because the hypothesis came from code reading while modelling and a JUnit witnessed it.
No prior report: 63 query lines over GitHub, commits, downstream code, four mailing lists, the JIRA archive and the web (2026-10-26), refreshed on GitHub 2026-10-31. The closest items are the change that made the promise (#15378, with #14515) and the older completion-only await (LUCENE-8962, LUCENE-10216).
Searches4 searches
Nearest items, none this bug: #15378 (added MergeObserver and the two @return lines), #14515 (the feature request), #14548 (superseded attempt), #14431 (open: a timeout for forceMergeDeletes), LUCENE-8962 (lucene-solr #1585, #10005) and LUCENE-10216 (#633, #11253), which built the completion-only await and hasCompletedSuccessfully. Of the 413 open PRs, only #16571 and #16847 touch MergePolicy.java, and neither changes await, mergeCompleted or MergeObserver. Related in the hunt: LFV-207, the same observer with a different root cause (await() blocks forever when IndexWriter refused to register a merge). Limits: GitHub search is capped per query; no search proves absence; private reports are invisible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-26 | the scripted prior-report search (16 issue/PR queries) plus manual searches for MergeObserver, awaitAsync, "merge observer"; full discussions of 10 related items read | no report; #15378 and #14515 are the origin |
| Open PRs | 2026-10-26 | all 413 open PRs listed with their changed files | #16571 and #16847 touch MergePolicy.java; neither changes await or MergeObserver |
| Commits, JIRA archive, downstream code, mailing lists (dev@, java-user@, issues@, commits@), web | 2026-10-26 | 3 commit, 4 JIRA, 2 downstream, 20 list and 6 web queries; 142 distinct hits overall | LUCENE-8962 and LUCENE-10216 (origin of the completion-only await); no report |
| GitHub issues and PRs (refresh) | 2026-10-31 | 8 queries: MergeObserver, forceMergeDeletes await, hasCompletedSuccessfully, awaitAsync, MergeSpecification await, "merge observer", forceMergeDeletes updated since 2026-10-25, open PRs on MergePolicy | nothing new; #16742 (updated 2026-10-28) touches only new merge-policy files |
Introduced in 10.4.0 by #15378 (merged 2025-11-02), which made forceMergeDeletes return the observer and documented false on error on top of an older completion-only await. Every release since fails (10.4.0 to 10.6.0); 10.3.2 and 9.x have no MergeObserver. Not a regression.
Versions tested4 rows
Introduced by #15378 "Return MergeObserver from IndexWriter.forceMergeDeletes() (#14515)" (author salvatorecampagna, merged 2025-11-02): c85c69f19082 on main and the backport 6862ef978845 on branch_10x, first released in 10.4.0; git log -S 'class MergeObserver' finds only these two commits. The completion-only package-private layer underneath is older and was not a bug on its own: await(timeout) from LUCENE-8962 (lucene-solr #1585, 59efe22ac29, 2020) and await() from LUCENE-10216 (#633, 698f40ad51a, 2022), documented as waiting for completion; the one caller of await(), addIndexes(CodecReader...), checks success separately (IndexWriter.java:3313-3315), and the callers of await(timeout) (the merge-on-commit and merge-on-getReader waits, :739, :3894) ignore its result. The MergeObserver class is identical in 10.6.0 and main; unchanged on upstream main 2f424fd428da.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the one test fails (gradle; harness -ea and -da) |
| 10.6.0 | affected | the one test fails (-ea and -da) |
| 10.4.0, 10.5.0, 10.5.1, 10.5.2 | affected | the hunt's test fails (-ea; also -da on 10.5.2) |
| 10.3.2, 9.12.3 | API absent | no MergeObserver; forceMergeDeletes returns void, the test does not compile |
Cross-model reviews (OpenAI GPT-5.6, both executed), an independent reproducer, a devil's advocate and an adjudication (confirmed, high) each ran their own test and found it real; the reviews rated the "works as designed" argument strong but did not dispute the mismatch. The reproducer and the devil's advocate found the rollback() and deleteAll() paths.
Checks and verdicts243 words
Each check had its own lens and ran code itself; the adjudication also re-ran the independent reproducer's test (assertions on and off) and the devil's advocate's (assertions on). Before publication an independent fact check re-ran the repro, fix A and those two tests and traced where the future completes, and a cross-model fact check reviewed the text; their corrections are applied.
| Check | Verdict | What it found |
|---|---|---|
| cross-model review, refute lens | real, high | Own test fails on main (both awaits true after a merge error) while its successful-merge control passes; root cause agreed; intended argument rated strong. |
| independent reproducer | real, low severity | Own public-API test outside org.apache.lucene: fails 30 of 30 iterations (assertions on and off), no-fault control passes 30 of 30; fails on 10.4.0 and 10.5.2; also true after a rollback() abort. |
| cross-model review, works-as-designed lens | real, high | Own characterization test: both awaits true and awaitAsync() normal after a merge IOException; the blocking forceMergeDeletes(true) control reports the failure. |
| devil's advocate | real (high on the violation, medium on code vs doc fix) | Own test fails 2 of 2 on main, 10.4.0 and 10.5.2: the I/O failure and a deleteAll() abort with the writer open and no tragic exception; found nothing that calls the behaviour intended. |
| adjudication | confirmed, high | Re-ran the reproducer's and the devil's advocate's tests (fail, controls pass) and checked by grep that the futures are only ever completed normally. |
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 449 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,288 words
- Registry title
- MergeObserver.await() and await(timeout) return true after a failed or aborted merge, although their javadoc says they return false on error
- Area
- index / IndexWriter forceMergeDeletes MergeObserver (MergePolicy.MergeObserver, MergeSpecification.await)
- Priority
- Low, rank 939: MergeObserver.await() returns true after a failed or aborted merge, misleading only callers of the experimental non-blocking forceMergeDeletes.
- Impact class
- minor
- Impact note
- Hits only applications that call the non-blocking IndexWriter.forceMergeDeletes(false) (released since 10.4.0; MergeObserver is @lucene.experimental) and treat the observer's await() or await(timeout) result as a success signal, and only when a forced-deletes merge fails with an I/O error (for example a full disk, which is tragic and closes the writer anyway) or is aborted by the application's own rollback() or deleteAll() (the writer stays open with no tragic exception): await() then returns true instead of false; default IndexWriterConfig, ConcurrentMergeScheduler and TieredMergePolicy suffice, no scale is needed, nothing is persisted wrongly, deleted documents stay hidden, the blocking forceMergeDeletes(true) still throws, and Elasticsearch, OpenSearch and Solr call the blocking form (read-only code search, not exhaustive).
- Summary
- You call IndexWriter.forceMergeDeletes(false), which starts the merges in the background and returns a MergeObserver, and then call observer.await() to learn whether they worked; its javadoc says it returns true if all merges completed successfully and false on error. Instead it returns true even when the merge failed with an IOException or was aborted by rollback() or deleteAll(), because every merge's completion future is completed normally with a success flag that await() never reads. A caller that trusts the result believes the deleted documents were merged away although the merge never finished.
- Introduced
- 6862ef978845 (branch_10x backport, released in 10.4.0) and c85c69f19082 (main, unreleased 11.0), the same patch: #15378 'Return MergeObserver from IndexWriter.forceMergeDeletes() (#14515)' by salvatorecampagna, merged 2025-11-02 (fixes #14515) [read: git log -S 'class MergeObserver' finds only these two commits, and git blame attributes every line of MergeObserver.await()/await(timeout) and their @return javadoc (MergePolicy.java:905-929) to c85c69f1908; the release boundary is executed: the repro does not compile on 10.3.2 (no MergeObserver) and fails on 10.4.0]; the patch put the public 'false on error' promise on top of the older, completion-only package-private MergeSpecification.await, which it did not change: await(timeout) from LUCENE-8962 (#1585, 59efe22ac29, 2020), await() from LUCENE-10216 (#633, 698f40ad51a, 2022) (2025-11-02 (#15378, merged 2025-11-02T23:36:21Z on main and branch_10x))
- First affected version
- 10.4.0 [exec] (first release with MergePolicy.MergeObserver); 10.3.2 and every earlier release, including the whole 9.x line, do not have the API (forceMergeDeletes returns void; the repro does not compile on 10.3.2 and 9.12.3 [exec])
- Affected versions
- 10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 (executed with the one-test repro, -ea and -da, 2026-10-31) and main ea57360912ed: every release that contains MergeObserver (10.5.2 was the latest on Maven Central when checked on 2026-10-26). [exec] the finder's JUnit fails on all four releases with -ea, and on 10.5.2 and main also with -da; the independent reproduction's own repro fails on main (-ea, -da), 10.5.2 (-ea, -da) and 10.4.0; the intended-behaviour check's fails on main (-ea, -da), 10.4.0 and 10.5.2; the finder's repro also fails with open PR #16889 applied (heads e304ea8cbd6, 2026-10-26, and 6c281d1cb52, 2026-11-02 on main e1faeba08bd with -ea and -da). MergeObserver, MergeSpecification.await()/await(timeout) and OneMerge.close are source-identical to main at all four tags. Not affected: 10.3.2 and earlier, 9.x (API absent) [exec: compile error on 10.3.2 and 9.12.3]. The completion-only package-private MergeSpecification.await already returns true for a failed merge on 9.12.3, 10.3.2, 10.4.0, 10.5.2 and main [exec: mechanism probe], but there it made no 'false on error' promise and had no public caller. branch_10x and branch_10_6 are not in the local history clone; prior-report record found no commit touching MergePolicy.java on them after the #15378 backport [read, GitHub API].
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: code reading
- Upstream and prior reports
- none found (2026-10-26, follow-up prior-report record verdict new: 63 query lines, among them a scripted prior-report search over GitHub issues/PRs (16 queries), commits (3), downstream repositories (2), the dev@, java-user@, issues@ and commits@ lists (20), the JIRA archive (4) and the web (6), 142 distinct hits; manual GitHub searches; the bodies, comments and reviews of apache/lucene #15378, #14515, #14548, #14431, #633, #10005, #11253, #13354 and apache/lucene-solr #1585, #1617; the file lists of all 413 open apache/lucene PRs; the local git history); no issue, PR, review comment, commit, list thread or JIRA entry describes await() or await(timeout) returning true after a failed or aborted merge, or the javadoc/code mismatch; not fixed upstream: MergeObserver is unchanged on main ea57360912ed, MergePolicy.java has no commit on branch_10x, branch_10_6, branch_10_5 or branch_10_4 after the #15378 backport, and the only open PRs touching MergePolicy.java (#16571, #16847) do not change await, mergeCompleted or MergeObserver; related, not reports of this bug: #15378 (introduced the public API and the two '@return ... false on error' lines; its reviews cover naming, numCompletedMerges and toString, its tests success, no-merge and timeout only), #14515 (the feature request), #14548 (superseded earlier attempt), #14431 (open: a timeout for forceMergeDeletes), lucene-solr #1585 / LUCENE-8962 and #10005 (2020 origin of the completion-only await and of hasCompletedSuccessfully), #633 / LUCENE-10216 (addIndexes checks success after await), #13354, #12228, #13593 (other merge issues; the repro also fails with #13593's open fix PR #16889 applied (heads e304ea8cbd6 and 6c281d1cb52)); gaps: one manual GitHub query hit the REST rate limit and was not retried (the tool's broader forceMergeDeletes query covers it), JIRA full-text is limited to the archive's index, mailing lists were searched through the lists.apache.org API only, private security reports cannot be searched
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the one-test repro through gradle in a scratch worktree (assertions on; removed afterwards) and through the hunt repro harness (assertions on and off); fix A through gradle (the repro and the 9 closest suites, 172 tests, assertions on and off; all of:lucene:core:test, 8,898 tests, assertions on) and shadowed in the harness (the repro, and the independent reproducer's and the devil's advocate's tests, which cover therollback()anddeleteAll()aborts). The 10.6.0 release jars with the one-test repro (assertions on and off). The hunt ran on mainea57360912ed(the same two files) with history runs on 9.12.3 and 10.3.2 (API absent), 10.4.0, 10.5.0, 10.5.1 and 10.5.2. Upstream main2f424fd428da(2026-10-30) has no commit touchingMergePolicy.javaorIndexWriter.javasince645d4fe14a20. OpenJDK 25.0.3, Linux aarch64 (the only platform run).
- Finding record
a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:284-290 (OneMerge.close: mergeCompleted.complete(success)), :528-557 (getMergeCompletedFutures, MergeSpecification.await() and await(timeout)), :864-929 (MergeObserver class javadoc, await() and await(timeout) with their @return lines)a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2336-2405 (forceMergeDeletes(boolean) and its @return), :2406-2411 (TieredMergePolicy picks a segment only above 10% deletes), :2743-2745 (deleteAll aborts running merges; forceMergeDeletes callers 'may receive MergeAbortedExceptions'), :3308-3315 (addIndexes(CodecReader...) checks hasCompletedSuccessfully() after spec.await())
- The finding in 30 seconds
a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:192, :276-299, :452-459, :528-557, :864-942 (read at 645d4fe14a20)a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2337-2405, :2408-2411 (TieredMergePolicy 10%), :2621, :2743-2745, :2771, :2821-2861, :3313-3315, :5043-5053, :5065-5114, :5133-5136, :5394-5402, :5467-5483, :5808-5813, :6113 (read at 645d4fe14a20)
- Who is affected
a Lucene main checkout lucene/test-framework/src/java/org/apache/lucene/tests/index/RandomIndexWriter.java:437-447; lucene/luke/src/java/org/apache/lucene/luke/models/util/IndexUtils.java:285 (grep of lucene/*/src/java)
- Why it looks unintended
a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:864-942 (class javadoc, @return lines; read at 645d4fe14a20)a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2343, :2743-2745, :3313-3315 (read at 645d4fe14a20)
- Suggested fix
a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3313-3315, :5013 (read at 645d4fe14a20)
- TestMergeObserverAwaitOnFailure.java (one test):
repro/LFV-188/src/org/apache/lucene/index/TestMergeObserverAwaitOnFailure.java(52 lines)
assets/LFV-188/bug.png(449 KB)
