All findings · Lucene finding · LFV-188 · mergeobserver-await-ignores-failure

MergeObserver.await() returns true after a failed or aborted merge, although its javadoc says false on error

After IndexWriter.forceMergeDeletes(false), MergeObserver.await() returns true even when the merge failed with an IOException or was aborted by rollback() or deleteAll(), although its javadoc promises false on error. Every release with the observer, 10.4.0 to 10.6.0, has it; a three-line check of the success flag Lucene already records fixes it.

Summary

Priority
Low, rank 939MergeObserver.await() returns true after a failed or aborted merge, misleading only callers of the experimental non-blocking forceMergeDeletes.
Impact
minor
Since
10.4.0 (#15378, 2025)introduced 2025-11-02 · about 1 year
Affected
10.4.0 to 10.6.0 (the latest release) and main
Status
documented
Found by
semi-formal: code reading while writing a TLA+ model of the merge lifecycle

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background: forceMergeDeletes and its observer

  1. IndexWriter.forceMergeDeletes() rewrites segments that hold many deleted documents, so those documents are really dropped. The merge policy picks the segments; TieredMergePolicy takes a segment over 10% deleted by default.
  2. Since 10.4.0, forceMergeDeletes(false) does not wait: with the default ConcurrentMergeScheduler the merges run on background threads, and the call returns an experimental MergeObserver with numMerges(), numCompletedMerges(), await(), await(timeout, unit) and awaitAsync().
  3. 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.
Diagram of the minimal repro: forceMergeDeletes(false) schedules 1 merge and returns a MergeObserver; on the merge thread the merge hits an IOException, and OneMerge.close completes the merge's future normally with the value false; the caller's observer.await() waits for the future, ignores its value and returns true, although the javadoc says false on error; the writer records the IOException as its tragic exception.
The merge thread hits the 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.

The finding, step by step

  1. Use the default IndexWriterConfig with a ConcurrentMergeScheduler and a TieredMergePolicy with setForceMergeDeletesPctAllowed(0). Add 10 documents and delete 1. The Directory throws an IOException whenever a merge writes a file, as a full disk would.
  2. Call forceMergeDeletes(false). It schedules 1 merge on a background thread and returns a MergeObserver at once. Call observer.await().
  3. You expect false: the javadoc says await() returns true "if all merges completed successfully" and false "on error".
  4. Instead it returns true, while the writer records the merge's IOException as its tragic exception. The failed merge completes its future normally with a success flag of false; await() only waits for the futures and never reads the flag.
  5. With no fault at all, rollback() or deleteAll() aborting the running merge also gives true; after deleteAll() the writer stays open with no tragic exception.

Who is affected

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.

The code (main 645d4fe14a20)

// 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();
}

Step by step, on the repro

  1. 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 returns new MergePolicy.MergeObserver(spec); with doWait == false it skips the loop that checks for failures (:2369-2397).
  2. The merge fails on the merge thread: the Directory throws when the merge creates its first file. The exception is thrown inside mergeMiddle's try (IndexWriter.java:5482-5483), and mergeMiddle's own finally calls closeMergeReaders(merge, true, false) (:5808-5813), which calls merge.close(false, ...) (:5394-5402): mergeCompleted.complete(false), before the exception reaches IndexWriter.merge (:5065-5114). There handleMergeException rethrows it; the second closeMergeReaders in merge's finally (:5092) is a no-op (hasFinished(), :5397), and only after that finally does tragicEvent(t, "merge") record the failure (:5112). A probe traced the completing call: closeMergeReaders:5400 <- mergeMiddle:5812 <- merge:5081.
  3. await() returns true. allOf(...).get() returns normally once the future is complete; the Boolean is never read. Nothing in org.apache.lucene.index completes mergeCompleted exceptionally or cancels it (it is private final; grep finds no completeExceptionally or cancel), so the false branches are reachable only by a timeout in await(timeout).
  4. An abort ends the same way. rollback() and deleteAll() call abortMerges() (IndexWriter.java:2621, :2771, :2821-2861): a pending merge goes through abortOneMerge (:5133-5136, closeMergeReaders(merge, true, false)), a running one stops with a MergeAbortedException that handleMergeException swallows (:5043-5053), then merge's finally completes the future (:5092; probe: closeMergeReaders:5400 <- merge:5092). No tragic event.
  5. The outcome is recorded but not public. OneMerge.hasCompletedSuccessfully() (MergePolicy.java:452-459) returns the flag, and addIndexes(CodecReader...) checks it right after its own spec.await() (IndexWriter.java:3313-3315). The observer's numCompletedMerges() counts failed merges as completed (isDone(), :901) and awaitAsync() 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 hook OneMerge.mergeFinished(success, segmentDropped).
Exposure and likelihood369 words
  • Who is hit. Applications that call forceMergeDeletes(false) (since 10.4.0; MergeObserver is @lucene.experimental) and use await() or await(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 own rollback() or deleteAll() 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 true from 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 called await() and commit(), 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 after await() returns. An abort records no tragic exception, and after deleteAll() the writer stays open, so await()'s true is 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 calls MergeObserver.await() (only the test framework's RandomIndexWriter passes 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.

Minimal repro

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.

TestMergeObserverAwaitOnFailure.java (one test)52 lines
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);
  }
}
Command
# $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() returns false after the failed merge, as its javadoc says ("false on error").
Observed
await() returns true: expected await() == false after the failed merge, got true, while getTragicException() holds the merge's IOException.

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

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.

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64

Observed output

# 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)

Why it looks unintended

  • The javadoc is explicit: await() returns true "if all merges completed successfully", false "on error" (MergePolicy.java:912-913).
  • That false can never happen: the futures always complete normally, and await() returns false only 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(): "@return true if all merges completed successfully or no merges were needed, false on error". :925-926, await(timeout, unit): "@return true if all merges completed within timeout or no merges were needed, false on timeout or error".
  • The documented false is unreachable. The only false branches catch ExecutionException and CancellationException (untimed) or ExecutionException and TimeoutException (timed); nothing completes the futures exceptionally or cancels them, and an interrupt throws instead of returning false. 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 running forceMerge, addIndexes(CodecReader[]) or forceMergeDeletes methods, they may receive MergePolicy.MergeAbortedExceptions" (IndexWriter.java:2743-2745).
  • History: an oversight when the API went public. #15378 (2025-11-02) wrapped the package-private MergeSpecification.await methods unchanged and wrote the two @return lines; git blame gives every line of both await methods and their javadoc to that change. Its review (23 inline comments, 16 reviews) covers naming, numCompletedMerges and toString, 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() returns true after 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).

Suggested fix

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=true and false: TestMergeObserverAwaitOnFailure, TestIndexWriterMerging (the upstream MergeObserver tests), TestMergePolicy, TestAddIndexes, TestConcurrentMergeScheduler, TestIndexWriterForceMerge, TestTieredMergePolicy, TestIndexWriterExceptions, TestIndexWriterOnDiskFull, TestIndexWriterDelete: 172 tests, 11 skipped, 0 failures each time.
  • All of :lucene:core:test with 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).
  • checkGoogleJavaFormat reports no violation in MergePolicy.java.

B. Correct the two @return lines and add a public success query

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.

C. Change the package-private MergeSpecification.await() instead (not recommended)

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 note

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.

How it was found

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 / IndexWriter merge lifecycle in TLA+, with a merge-completion property (every merge ends closed). Writing that property meant reading how merges complete, and the finder saw that OneMerge.close completes the future normally with a success flag that the public await() 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 a FilterDirectory that 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-26the scripted prior-report search (16 issue/PR queries) plus manual searches for MergeObserver, awaitAsync, "merge observer"; full discussions of 10 related items readno report; #15378 and #14515 are the origin
Open PRs2026-10-26all 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@), web2026-10-263 commit, 4 JIRA, 2 downstream, 20 list and 6 web queries; 142 distinct hits overallLUCENE-8962 and LUCENE-10216 (origin of the completion-only await); no report
GitHub issues and PRs (refresh)2026-10-318 queries: MergeObserver, forceMergeDeletes await, hasCompletedSuccessfully, awaitAsync, MergeSpecification await, "merge observer", forceMergeDeletes updated since 2026-10-25, open PRs on MergePolicynothing new; #16742 (updated 2026-10-28) touches only new merge-policy files

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one test fails (gradle; harness -ea and -da)
10.6.0 affectedthe one test fails (-ea and -da)
10.4.0, 10.5.0, 10.5.1, 10.5.2 affectedthe hunt's test fails (-ea; also -da on 10.5.2)
10.3.2, 9.12.3 API absentno MergeObserver; forceMergeDeletes returns void, the test does not compile

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
cross-model review, refute lens real, highOwn 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 severityOwn 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, highOwn 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, highRe-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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,288 words

Finding record

LFV-188, snapshot of 2026-11-06

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 the rollback() and deleteAll() aborts). The 10.6.0 release jars with the one-test repro (assertions on and off). The hunt ran on main ea57360912ed (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 main 2f424fd428da (2026-10-30) has no commit touching MergePolicy.java or IndexWriter.java since 645d4fe14a20. OpenJDK 25.0.3, Linux aarch64 (the only platform run).

Lucene and upstream sources

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)

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

Repro files

About the diagrams

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.