All findings · Lucene finding · LFV-1174 · abortpendingflushes-error-orphans-queued-flushing-dwpts

An Error while IndexWriter aborts the queued segments of a failed flush hangs its self-close forever

If an Error, in practice an OutOfMemoryError, hits IndexWriter while it throws away the queued in-memory segments of a failed flush, the clean-up loop stops halfway and the rest stay marked as flushing. The writer's own self-close then waits for them forever: DirectoryReader.open(writer) never returns, in every release since 7.4.0.

Summary

Priority
Low, rank 796On default settings, an Error (in practice an OutOfMemoryError) thrown while IndexWriter aborts a queued in-memory segment after a failed full flush makes DirectoryReader.open(writer) and the writer's own self-close wait forever (later rollback and close calls too, executed; by reading also commit and flush), in every release since 7.4.0. It needs a failed flush with three or more queued segments followed by an Error at that exact step, and many servers exit the JVM on an OutOfMemoryError anyway, so it is contrived and Low.
Impact
hang
Since
7.4.0 (LUCENE-8215, 2018)introduced 2018-03-19 · about 8 years
Affected
7.4.0 through 10.6.0 and main (7 releases and main run, 59 release tags read); not fixed upstream
Status
documented
Found by
non-formal: code reading of DocumentsWriterFlushControl's exception handling against its flushingWriters accounting, witnessed by an executable repro

AI-assisted page: this finding was first signalled by code reading or testing 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

Background: how IndexWriter flushes

  1. Each indexing thread fills its own in-memory segment (inside Lucene: a DWPT). Three threads indexing at once give three of them.
  2. A full flush (DirectoryReader.open(writer), commit(), flush()) checks them all out, registers each in a set of flushing segments and queues them. A segment leaves that set only through one method, doAfterFlush.
  3. After any Error, IndexWriter closes itself. Part of that close waits until the set of flushing segments is empty.
Diagram of the bug: IndexWriter holds three in-memory segments, all registered as flushing. Segment 1's flush fails and it is released. abortPendingFlushes aborts segment 2; an OutOfMemoryError is thrown, the finally releases segment 2, and the Error leaves the loop, which catches only Exception. Segment 3 is never visited and stays registered. The tragic self-close calls waitForFlush, which waits until no segment is flushing, so DirectoryReader.open(writer) never returns.
Three in-memory segments are queued for a full flush. Segment 1's flush fails; the clean-up loop releases segment 2 when the Error hits it and stops, so segment 3 stays registered as flushing, and the writer's self-close waits for it forever. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Three threads call addDocument at the same time on one IndexWriter with the default config, so the writer holds 3 in-memory segments of 1 document each.
  2. DirectoryReader.open(writer) flushes all 3. Writing segment 1 fails with an OutOfMemoryError (the repro simulates it in IndexOutput.close()), so the writer throws away segments 2 and 3.
  3. You expect what the IndexWriter javadoc promises: the open throws the Error and the writer closes itself. On 7.3.1 an earlier, longer version of this test passes: the open throws and the writer is closed.
  4. Instead, a second Error while throwing away segment 2 ends the clean-up loop, which catches only Exception. Segment 3 is never released and stays registered as flushing.
  5. The self-close waits until no segment is flushing, so DirectoryReader.open(writer) never returns: the repro times out after 20 s, with assertions on and off.

Who is affected

Applications that index from several threads and open NRT readers on the default configuration (commit and flush reach the same code), and keep the JVM running after an OutOfMemoryError. The thread hangs silently and the writer's self-close never finishes. It needs a failed full flush with 3 or more in-memory segments, then an Error at that step: rare.

Mechanism, terms and numbers433 words

When several threads index into one IndexWriter, each holds its own in-memory segment. A full flush (DirectoryReader.open(writer), commit(), flush()) queues them all. If flushing the first one fails, DocumentsWriterFlushControl.abortPendingFlushes aborts the rest, but it wraps each abort in catch (Exception _). An Error thrown while aborting one of them (in practice an OutOfMemoryError) ends the loop: the segments it has not reached stay registered in flushingWriters, and nothing ever removes them. IndexWriter then closes itself, as it documents for any Error, and that close waits in waitForFlush for those segments forever, so DirectoryReader.open(writer) never returns. Until LUCENE-8215 (2018, first release 7.4.0) the loop caught Throwable to "keep on aborting the flush queue". Every release since is affected. It is realistically OutOfMemoryError-only, so it is Low priority.

The bug, step by step (main 5adff78bb168)

  1. Three segments queued. DirectoryReader.open(writer) -> IndexWriter.getReader -> flushAllThreads() (IndexWriter.java:586). markForFullFlush checks out every segment with documents; checkOutForFlush adds each to flushingWriters (DocumentsWriterFlushControl.java:427, :437-441) and all go into flushQueue (:626).
  2. The first flush fails. maybeFlush takes segment 1 from the queue (DocumentsWriter.java:494) and flushes it; IndexOutput.close() throws, and doFlush's finally releases segment 1 with doAfterFlush (DocumentsWriter.java:577). Segments 2 and 3 are still queued and registered. Any throwable in a segment's flush is an aborting exception, so the writer is already tragic here (DocumentsWriterPerThread.java:585-603); an IOException would do as well.
  3. The abort loop meets an Error. getReader calls finishFullFlush(false) (IndexWriter.java:669-671) -> abortFullFlushes (DocumentsWriter.java:738) -> abortPendingFlushes (DocumentsWriterFlushControl.java:694-723). Aborting segment 2 closes its open stored-fields writer through IOUtils.closeWhileHandlingException (StoredFieldsConsumer.java:112-113), which rethrows Errors on purpose ("we ensure that we bubble up any errors", util/IOUtils.java:155-178).
  4. Segment 3 is stranded. catch (Exception _) (:700, and :712 for blocked flushes) does not take the Error. The per-entry finally releases segment 2 only, the Error leaves the loop, and the outer finally clears the queue (:718-722) while segment 3 is still in flushingWriters. Only doAfterFlush (:298-301) removes it, and nothing will call it.
  5. The self-close waits forever. getReader's catch (Throwable) marks the Error as tragic and runs maybeCloseOnTragicEvent (IndexWriter.java:711-722) -> rollbackInternalNoCommit -> docWriter.abort() (:2635) -> waitForFlush() (DocumentsWriter.java:226), an untimed wait() while flushingWriters is not empty (DocumentsWriterFlushControl.java:361-369).

A RuntimeException thrown at the same point is caught and the open returns promptly, with no hang (the controls of the cross-model reviewers; 1.3 s in one), so the catch (Exception) boundary is the defect.

Exposure and likelihood210 words
  • Who. Multi-threaded indexing (IndexWriter is documented as "completely thread safe", IndexWriter.java:167) with NRT reopen, commit or flush, on the default configuration; the injected Error is a fault, not a setting.
  • Effect. A silent hang, not an exception: the thread that opened the reader blocks forever [exec]. By reading only: commit() (IndexWriter.java:3875, :3879) and flush() (:4411, :4427-4434) reach the same loop. Executed by the fact check's probe: any later rollback() or close() of that writer never returns either, because the hung self-close has already set closing (:6102, :1379), so they wait in shouldClose(true) -> doWait() (:1387, :5867).
  • Data. The hang comes before the write.lock release (:2635 runs before :2684), so the last commit on disk is untouched, write.lock stays held in that JVM until the process exits, and buffered documents are lost, as in any tragic close [read].
  • Scale. Three in-memory segments and one Error; no size threshold; it does not depend on assertions.
  • How rare. Contrived: a full flush must fail, then an Error must hit the abort of a queued segment that is not the last one. Many servers exit the JVM on OutOfMemoryError. No report of this hang was found since 7.4.0.

Minimal repro

One JUnit test method, public classes only, default config (default codec pinned). Three threads each add one document at the same time (a latch holds them, so there are 3 in-memory segments); then every IndexOutput.close() throws a simulated OutOfMemoryError and the test calls DirectoryReader.open(writer). @Timeout(20 s) is the hang oracle. Deterministic; fails with assertions on and off.

TestErrorAbortingQueuedSegments.java (lucene/core, package org.apache.lucene.index, 1 test)91 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.index;

import com.carrotsearch.randomizedtesting.annotations.Timeout;
import java.io.IOException;
import java.io.StringReader;
import java.io.UncheckedIOException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.TextField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.FilterDirectory;
import org.apache.lucene.store.FilterIndexOutput;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestErrorAbortingQueuedSegments extends LuceneTestCase {

  // Expected: DirectoryReader.open throws the Error and the writer closes itself; actual: it hangs.
  @Timeout(millis = 20_000)
  public void testErrorWhileAbortingQueuedSegments() throws Exception {
    AtomicBoolean fail = new AtomicBoolean();
    FilterDirectory dir =
        new FilterDirectory(new ByteBuffersDirectory()) {
          @Override
          public IndexOutput createOutput(String name, IOContext context) throws IOException {
            return new FilterIndexOutput("x", name, super.createOutput(name, context)) {
              @Override
              public void close() throws IOException {
                out.close();
                if (fail.get()) throw new OutOfMemoryError("simulated");
              }
            };
          }
        };
    IndexWriter writer =
        new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()));
    CountDownLatch allInAddDocument = new CountDownLatch(3);
    Thread[] threads = new Thread[3];
    for (int i = 0; i < threads.length; i++) {
      Document doc = new Document();
      doc.add(
          new TextField(
              "f",
              new StringReader("x") {
                @Override
                public int read(char[] buf, int off, int len) throws IOException {
                  allInAddDocument.countDown();
                  try {
                    allInAddDocument.await();
                  } catch (InterruptedException e) {
                    throw new IOException(e);
                  }
                  return super.read(buf, off, len);
                }
              }));
      threads[i] =
          new Thread(
              () -> {
                try {
                  writer.addDocument(doc);
                } catch (IOException e) {
                  throw new UncheckedIOException(e);
                }
              });
      threads[i].start();
    }
    for (Thread t : threads) t.join();
    fail.set(true);
    expectThrows(OutOfMemoryError.class, () -> DirectoryReader.open(writer));
    assertFalse(writer.isOpen());
  }
}
Command
cp $REPRO/TestErrorAbortingQueuedSegments.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestErrorAbortingQueuedSegments -Ptests.seed=DEADBEEF
# also with -Ptests.asserts=false: the same hang
Expected
DirectoryReader.open(writer) throws the OutOfMemoryError and writer.isOpen() is false.
Observed
DirectoryReader.open(writer) never returns: Test timeout exceeded (>= 20000 msec), parked in DocumentsWriterFlushControl.waitForFlush:364.

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

More files, environment, full output419 words

TestErrorAbortingQueuedSegments (one method, extends LuceneTestCase, package org.apache.lucene.index of the lucene/core tests). The three threads only build the segments; the failing open runs on the test thread, and the outcome needs no timing. TestUtil.getDefaultCodec() pins the default codec against the test framework's random codec. No package-private member is used; FilterDirectory and FilterIndexOutput are public but marked @lucene.internal.

The same method is the regression test (it fits next to TestIndexWriterOnError). With a fix it passes in under 1.5 s. Older releases need more than package renames: FilterIndexOutput first shipped in 9.6.0 (GITHUB-11868, #11958), so before 9.6.0 wrap IndexOutput by hand, as the hunt's earlier, longer test for 7.x/8.x does; on 7.x/8.x the test framework package is org.apache.lucene.util, and on 7.x RAMDirectory replaces ByteBuffersDirectory.

The fault is harsher than a natural one-off OutOfMemoryError: once fail is set, every IndexOutput.close() throws. By reading, the hang needs only a failed flush of the first segment (any exception) and then one Error while a queued segment that is not the last is aborted. During the hang writer.isOpen() already returns false (IndexWriter.java:6117-6118), so it does not show that the close finished.

The timeout report also lists a leaked thread (the test thread still parked in waitForFlush) and a stdout-limit failure (the timeout's thread dump); both are consequences of the hang.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): hangs; 10.6.0 and 9.12.3: this test hangs too; with an earlier, longer version of the test, 7.4.0 to 10.6.0 hang (7 releases run) and 7.3.1 passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64, the only platform run (nothing platform-specific: plain Java exception handling and Object.wait())
Command (environment)
cp $REPRO/TestErrorAbortingQueuedSegments.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestErrorAbortingQueuedSegments -Ptests.seed=DEADBEEF

Observed output

# gradle, scratch worktree at 5adff78bb168, -Ptests.seed=DEADBEEF (assertions on)
TestErrorAbortingQueuedSegments > testErrorWhileAbortingQueuedSegments FAILED
    java.lang.Exception: Test timeout exceeded (>= 20000 msec).
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:C9B68B5A67B45519]:0)
    ... (the leak report, after the timeout):
        com.carrotsearch.randomizedtesting.ThreadLeakError: 1 thread leaked from SUITE scope at org.apache.lucene.index.TestErrorAbortingQueuedSegments:
       1) Thread[id=21, name=TEST-TestErrorAbortingQueuedSegments.testErrorWhileAbortingQueuedSegments-seed#[DEADBEEF]-worker, state=WAITING, group=TGRP-TestErrorAbortingQueuedSegments]
            at org.apache.lucene.index.DocumentsWriterFlushControl.waitForFlush(DocumentsWriterFlushControl.java:364)
            at org.apache.lucene.index.DocumentsWriter.abort(DocumentsWriter.java:226)
            at org.apache.lucene.index.IndexWriter.rollbackInternalNoCommit(IndexWriter.java:2635)
            at org.apache.lucene.index.IndexWriter.rollbackInternal(IndexWriter.java:2590)
            at org.apache.lucene.index.IndexWriter.maybeCloseOnTragicEvent(IndexWriter.java:6103)
            at org.apache.lucene.index.IndexWriter.getReader(IndexWriter.java:719)
            at org.apache.lucene.index.DirectoryReader.open(DirectoryReader.java:144)
            at org.apache.lucene.index.DirectoryReader.open(DirectoryReader.java:123)
            at org.apache.lucene.index.TestErrorAbortingQueuedSegments.lambda$testErrorWhileAbortingQueuedSegments$1(TestErrorAbortingQueuedSegments.java:88)
            at org.apache.lucene.tests.util.LuceneTestCaseParent._expectThrows(LuceneTestCaseParent.java:1587)
            ...
:lucene:core:test (FAILURE): 2 tests, 2  failures
# (the second failure is the suite-level report of the leaked thread and of the timeout's thread dump)

# same, -Ptests.asserts=false
TestErrorAbortingQueuedSegments > testErrorWhileAbortingQueuedSegments FAILED
    java.lang.Exception: Test timeout exceeded (>= 20000 msec).
    ... leaked thread parked at org.apache.lucene.index.DocumentsWriterFlushControl.waitForFlush(DocumentsWriterFlushControl.java:364)
:lucene:core:test (FAILURE): 2 tests, 2  failures

# with fix A: assertions on, then off
:lucene:core:test (SUCCESS): 1 test
:lucene:core:test (SUCCESS): 1 test

Why it looks unintended

  • The IndexWriter javadoc promises that after an Error the writer "will close itself"; here that close never finishes.
  • abortPendingFlushes was created (LUCENE-4277, 2012) to stop exactly this kind of rollback hang, catching Throwable to "keep on aborting the flush queue".
  • LUCENE-8215 (2018) narrowed it to Exception without discussing this loop; the fix still lets the Error propagate.
The full argument243 words
  • Contract. "if you hit an Error, or disaster strikes during a checkpoint then IndexWriter will close itself. ... Any subsequent calls will throw an AlreadyClosedException." (IndexWriter.java:162-165). getReader implements this for every Error (:711-722).
  • Purpose of the loop. LUCENE-4277 (#5345, b26dd733f77, 2012; CHANGES: "Fix IndexWriter deadlock during rollback if flushable DWPT instance are already checked out and queued up but not yet flushed") created abortPendingFlushes with catch (Throwable ex) { // ignore - keep on aborting the flush queue }. LUCENE-4989 (#6053) and LUCENE-5002 (c0e7a50ab8e, 2013) moved doAfterFlush into a per-entry finally so a failing abort could not skip the release.
  • The narrowing. LUCENE-8215 (#9262, 2e35ef2b3da, 2018, "Fix several fragile exception handling places in o.a.l.index") changed both catches to catch (Exception ex). Its discussion is about the new IOUtils helpers and never mentions this loop. The same commit added IOUtils.applyToAll ("even if an exception is thrown. The first exception ... is re-thrown"), the shape of the recommended fix.
  • Precedent. Upstream fixed Error-induced writer hangs as bugs: #12654 (fixed by #12751, 2023, an Error during the tragic close left rollback() waiting forever) and LUCENE-7570, whose test TestTragicIndexWriterDeadlock throws an OutOfMemoryError from mergeSuccess and expects commit() to fail fast and close() to return.
  • Tests. TestIndexWriterOnError injects fake Errors from I/O but runs "just one thread" (:59 of the test), so it never queues three segments in one full flush; no upstream test pins the hang.
The devil's-advocate check133 words

The strongest case for "not a bug": LUCENE-8215 narrowed the catch on purpose so that Errors are not swallowed, and after an OutOfMemoryError the JVM state is undefined, so Lucene owes nothing. Also, it needs two faults in one flush.

Why it fails: the narrowing meant that Errors should propagate, and the fix keeps that (it rethrows the first Error after every segment is released); nothing in #9262 says cleanup may stop halfway. IndexWriter itself documents a self-close after any Error and treats every Error as tragic; a hang breaks that promise and leaves the writer unclosable. The repro uses no InfoStream and no writer-internal hook: the Error comes from IndexOutput.close(), the kind of directory-I/O Error that Lucene's own TestIndexWriterOnError injects. Needing two faults lowers the likelihood (hence Low), not the validity.

Suggested fix

Recommended Catch Error per entry, finish both loops, rethrow the first Error (fix A)

In abortPendingFlushes, also catch Error per segment, keep the first (later ones suppressed), finish both loops so every queued and blocked segment reaches doAfterFlush, then rethrow it (+11 lines with the import). The repro then passes with assertions on and off, and so do nine IndexWriter suites (77 tests, 4 @Nightly skipped).

+    Error firstError = null;
     ... for each queued DWPT, then each blocked DWPT:
         } catch (Exception _) {
           // that's fine we just abort everything here this is best effort
+        } catch (Error e) {
+          // keep aborting so every DWPT reaches doAfterFlush, rethrow below
+          firstError = IOUtils.useOrSuppress(firstError, e);
         } finally {
           doAfterFlush(dwpt);
     ... the outer finally clears flushQueue and blockedFlushes ...
+    if (firstError != null) {
+      throw firstError;
+    }
All options and trade-offs2 options ยท 470 words

Option A was built and checked through gradle on 5adff78bb168; option B is the pre-7.4.0 code, which the hunt ran on 7.4.0. IOUtils.applyToAll is not a drop-in: it rethrows the first throwable of any type (util/IOUtils.java:566-577), so it would turn today's ignored abort exceptions into failures.

Catch Error per entry, finish both loops, rethrow the first Error (fix A) (validated by the checks) (recommended)

Every queued and blocked segment is aborted and released (unless doAfterFlush itself throws); the Error still propagates (after the cleanup), so LUCENE-8215's intent holds and the writer treats it as tragic. Exceptions stay ignored as today. The hunt's variant with explicit addSuppressed is the same idea and passed the repro too.

+    Error firstError = null;
     ... for each queued DWPT, then each blocked DWPT:
         } catch (Exception _) {
           // that's fine we just abort everything here this is best effort
+        } catch (Error e) {
+          // keep aborting so every DWPT reaches doAfterFlush, rethrow below
+          firstError = IOUtils.useOrSuppress(firstError, e);
         } finally {
           doAfterFlush(dwpt);
     ... the outer finally clears flushQueue and blockedFlushes ...
+    if (firstError != null) {
+      throw firstError;
+    }

Trade-offs

If the full flush failed with an exception and the abort then hits an Error, finishFullFlush(false) now throws that Error in place of the flush's exception, as any throw from it does today.

What the checks validated

  • Gradle in a scratch worktree at 5adff78bb168 (OpenJDK 25.0.3, seed DEADBEEF): without the fix the repro times out at 20 s with assertions on and off; with fix A it passes with assertions on and off (0.38 s and 0.36 s test time).
  • With fix A: the repro, TestIndexWriterOnError (5, 1 @Nightly skipped), TestTragicIndexWriterDeadlock (3), TestIndexWriterExceptions (40, 3 @Nightly skipped), TestIndexWriterExceptions2 (1), TestIndexWriterWithThreads (14), TestIndexWriterOnDiskFull (4), TestNRTReaderWithThreads (1), TestFlushByRamOrCountsPolicy (5) and TestDocumentsWriterStallControl (3): 77 tests, 0 failures, 4 skipped. :lucene:core:checkGoogleJavaFormat passes with the fix and the repro file in place.
  • The hunt's variant with explicit addSuppressed passed the same repro on the main jars too (assertions on 1.32 s, off 1.27 s, wall time).

Restore catch (Throwable), the pre-7.4.0 code (fix B, not recommended) (validated by the checks)

Two lines. The hunt's 7.4.0 run with only the LUCENE-8215 hunk reverted passes (0.62 s wall time, writer closed; run with the earlier, longer version of the test).

Trade-offs

It swallows the abort's Error. The writer still closes itself, because the failed flush already made it tragic (DocumentsWriterPerThread.java:585-603), but if the flush failed with an IOException and the abort then hit an OutOfMemoryError, the OOM would be neither thrown nor recorded (the tragedy and the thrown exception stay the IOException; by reading), against LUCENE-8215's aim of not swallowing Errors.

What the checks validated

Executed on 7.4.0 only (the hunt's history step), not on main.

Downstream note

Downstream, a service that catches OutOfMemoryError and keeps serving, instead of exiting, can be left with a hung indexing thread and a writer that never finishes closing, until the process restarts.

How it was found

Code reading of DocumentsWriterFlushControl: the exception handling of abortPendingFlushes was checked against the flushingWriters accounting that waitForFlush depends on. A JUnit test on public classes then witnessed the hang on main.

The method in full105 words

Method: non-formal (code reading, witnessed by an executable repro).

  • The finder asked which throwables can leave abortPendingFlushes and what each leaves behind in flushingWriters, the set waitForFlush waits on. catch (Exception _) lets an Error out, the outer finally clears the queue, and only doAfterFlush removes entries, so every segment after the one that threw stays registered.
  • DWPT.abort can throw an Error because IOUtils.closeWhileHandlingException rethrows Errors by design, and the tragic self-close after an Error runs waitForFlush.
  • The finder's first test threw the Error from an InfoStream; an independent reproducer then wrote the Directory-based test this repro is reduced from, with no InfoStream.

Prior reports, history and checks

Prior reports

No prior report in apache/lucene issues, PRs and commits, the JIRA archive, the mailing lists, downstream trackers or the web (searched 2026-11-03, re-checked). The nearest items are the history of this loop (#5345, #6053, #9262) and a fixed Error hang at another site (#12654).

Searches3 searches

Related, not reports: #5345 (LUCENE-4277, created the loop with catch (Throwable)), #6053 (LUCENE-4989, an abort exception skipped doAfterFlush; fixed by the per-entry finally), #9262 (LUCENE-8215, the introducing change), #12654/#12751 (an Error-induced tragic-close hang at another site, fixed 2023), #13277 (every Error tragic) and #10661 (LUCENE-9621, open: an OOM in flush trips an accounting assertion, no hang). The hunt's LFV-846 has the same waitForFlush symptom from a different cause and fix site (a throw in checkOutForFlush's stall logging); a fix for one does not fix the other.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, the migrated JIRA archive)2026-11-03, re-checkedscripted prior-report searches (two runs: 25 queries with 20 hits, 2 rated high overlap, both this loop's own 2012 history, #5345 = LUCENE-4277; 67 queries with 170 hits, none high; 181 distinct URLs), 54 manual searches (abortPendingFlushes, flushingWriters, waitForFlush, 'keep on aborting', 'best effort', Error/OOM/tragic/hang combinations, test names), 11 JQL queriesno report; abortPendingFlushes issues: only #6053
Open and recent PRs2026-11-03the changed files of all 409 open PRs; the 40 issues/PRs updated since 2026-11-01; upstream main's copy of the methodno PR touches the catch clauses; upstream main 1c2072f0284 still catches only Exception
Mailing lists, downstream, web2026-11-03dev@, java-user@, issues@ and commits@ (45 queries), Elasticsearch, OpenSearch, Solr and Lucene.NET trackers, 6 web queriesonly commit mails of the history above and general OOM Q&A

History

Introduced by LUCENE-8215 (2018), which narrowed the loop's catch (Throwable) to catch (Exception); first release 7.4.0. 7.3.1 passes, 7.4.0 hangs, and 7.4.0 with only that hunk reverted passes. All 59 releases since have the narrowed catch; the 7 run and main hang. Not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-8215 (#9262, Simon Willnauer): 2e35ef2b3da on main (first release 8.0.0) and its branch_7x cherry-pick ddb0bd78f7d (first release 7.4.0), author date 2018-03-19, no CHANGES entry. The older parts of the mechanism (the outer finally, which LUCENE-4277 carried into the new abortPendingFlushes in 2012; the per-entry finally from LUCENE-5002, 2013) were harmless while the catch took Throwable.

Tag scan: all 59 release tags 7.4.0 to 10.6.0 catch Exception there, all 64 tags 4.0.0-beta to 7.3.1 catch Throwable (whitespace-normalized; 9.x and 10.x put an annotation between catch ( and Exception). Not fixed upstream: main 1c2072f0284 (2026-11-02) still catches only Exception; the newest commit on the file, #16867 (2026-10-30), leaves the method alone.

The release runs used the independent reproducer's earlier, longer version of this test (same class name and fault model, a hand-written IndexOutput wrapper, its own 20 s bound; adapted for 7.x/8.x). The test shown in this doc was run on main and, by the fact check, on the 10.6.0 and 9.12.3 jars: it hangs on all three.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle: hangs with assertions on and off; passes with fix A
10.6.0, 10.5.2, 9.12.3 affectedhang (-da; 10.6.0 and 9.12.3 also with this doc's test)
8.0.0, 8.11.4 8.0.0: first main-line releasehang (-da, test adapted for the old framework)
7.4.0, 7.7.3 7.4.0: first releasehang (-da)
7.4.0 with the LUCENE-8215 hunk reverted not affectedpasses (0.62 s wall time), writer closed
7.3.1 last release beforepasses (0.62 s wall time), writer closed

Independent checks

The finder, an independent reproducer, two executed cross-model reviews (OpenAI GPT-5.6), a devil's-advocate verifier, an adjudication, an audit and a final repro check reproduced the hang and agree on the cause. This doc's gradle runs reproduced it on main and validated the fix.

Checks and verdicts293 words

The cross-model reviews ran on main ea57360912ed, whose abortPendingFlushes is identical to 5adff78bb168; every other check ran on 5adff78bb168 or a release.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedOwn InfoStream-based test hangs on main (-ea, -da), 10.6.0 and 9.12.3; with the fix it passes and four suites pass 15 of 15.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own test hangs with -ea and -da; its RuntimeException control returns; the fix makes both pass.
independent reproducer realOwn Directory-Error test (the basis of the repro) hangs on main (-ea, -da), 10.6.0, 10.5.2 and 9.12.3; with its fix the Error surfaces and the writer is closed.
cross-model review, intended lens (OpenAI GPT-5.6, executed) real (high)Own three-segment test hangs while the identical RuntimeException control returns in 1.3 s; with the fix TestIndexWriterOnError 4/4 and TestTragicIndexWriterDeadlock 3/3 pass.
devil's-advocate verifier real (high)Re-ran with another seed: hangs; own probe with no InfoStream hangs too; every 'intended' argument fails.
adjudication, audit and final repro check confirmed, LowClean 5adff78bb168 re-runs fail with -ea and -da and pass with the fix; one deterministic test on public classes.
this doc reproduced; fix A validatedGradle in a scratch worktree: the repro hangs with assertions on and off, and with fix A it and nine IndexWriter suites pass; checkGoogleJavaFormat passes.
fact checks (Claude; OpenAI GPT-5.6) real; wording fixedRe-ran the repro through gradle (hangs with assertions on and off and with a second seed; passes with fix A), ran it on the 10.6.0 and 9.12.3 jars (hangs), and probed later rollback() and close() calls (never return). Their attribution and wording corrections are applied.

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

Finding record

LFV-1174, snapshot of 2026-11-06

Registry title
An Error while IndexWriter aborts the queued in-memory segments of a failed full flush leaves the unvisited segments registered as flushing, so DirectoryReader.open(writer) and the writer's own self-close after the Error wait forever in waitForFlush (every release since 7.4.0)
Area
index / IndexWriter full-flush abort (DocumentsWriterFlushControl.abortPendingFlushes, doAfterFlush, waitForFlush)
Priority
Low, rank 796: On default settings, an Error (in practice an OutOfMemoryError) thrown while IndexWriter aborts a queued in-memory segment after a failed full flush makes DirectoryReader.open(writer) and the writer's own self-close wait forever (later rollback and close calls too, executed; by reading also commit and flush), in every release since 7.4.0. It needs a failed flush with three or more queued segments followed by an Error at that exact step, and many servers exit the JVM on an OutOfMemoryError anyway, so it is contrived and Low.
Impact class
hang
Impact note
Hits applications on the default configuration that index from several threads and open NRT readers when a full flush fails and an Error, realistically an OutOfMemoryError, is then thrown while the writer aborts a still-queued in-memory segment that is not the last one: DirectoryReader.open(writer) and the tragic self-close it starts never return [exec, -ea and -da], and by reading the same happens through commit() and flush(); any later rollback() or close() of that writer never returns either, because it waits in shouldClose -> doWait for the hung self-close [exec: the fact check's probe, -da]; no scale is needed (three in-memory segments), and every release from 7.4.0 through 10.6.0 and main is affected.
Summary
Several threads index into one IndexWriter, so it holds three or more in-memory segments, and you open an NRT reader; the full flush fails, and while the writer aborts the segments still queued behind the failed one an Error (realistically an OutOfMemoryError) is thrown. You expect what the IndexWriter javadoc promises: after an Error the writer closes itself, DirectoryReader.open(writer) throws, and later calls throw AlreadyClosedException. Instead abortPendingFlushes catches only Exception, so the Error leaves its loop after releasing just the current segment, the segments it has not visited yet stay registered as flushing with nothing left to release them, and the writer's self-close waits for them forever, so DirectoryReader.open(writer) never returns.
Introduced
2e35ef2b3daa1c1fdccbf78326d589e873489311 on the main line (first release 8.0.0) and its branch_7x cherry-pick ddb0bd78f7dbf207f8bf78fdcbb68914d98cdc3a (first release 7.4.0), the same change: LUCENE-8215 'Fix several fragile exception handling places in o.a.l.index' by Simon Willnauer (migrated issue #9262; reviewed on a pull request in the author's own fork, which now returns 404; no CHANGES entry). It changed both per-entry 'catch (Throwable ex) { // ignore - keep on aborting the flush queue }' clauses of DocumentsWriterFlushControl.abortPendingFlushes to 'catch (Exception ex)' [read: git log -L on abortPendingFlushes in the history clone; exec: 7.3.1 passes, 7.4.0 hangs, and 7.4.0 with only that hunk reverted in a shadow class passes]. The rest of the mechanism is older and was harmless while the catch took Throwable: the outer finally that clears the queues (LUCENE-4277, b26dd733f77, 2012) and the per-entry finally doAfterFlush (LUCENE-5002, c0e7a50ab8e, 2013). LUCENE-8205 and LUCENE-8212, both authored a few days before LUCENE-8215, let an Error out of DWPT.abort through IOUtils.closeWhileHandlingException; an Error from the InfoStream's 'now abort' line escapes DWPT.abort even without them. Individual 2018 commits were not built. (2018-03-19 (author date of 2e35ef2b3da and ddb0bd78f7d; committed 2018-03-20 on main and branch_7x); first release 7.4.0)
First affected version
7.4.0 [exec: the independent reproduction's earlier, longer version of the one-test repro (same class name and fault model), adapted to the 7.x test framework, hangs with -da]; 7.3.1 is not affected [exec: passes, the writer ends closed]; on the main line the first release is 8.0.0 [exec: hangs]. Releases before 4.0.0-beta have no abortPendingFlushes, and 4.0.0-beta to 7.3.1 catch Throwable there [read: tag scan]
Affected versions
Every release from 7.4.0 (2018) through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT); never fixed. [exec, JDK 25, Linux aarch64] the one-test repro TestErrorAbortingQueuedSegments hangs on main with -ea and -da (the final repro check and gradle) and on the 10.6.0 and 9.12.3 jars (-da, the fact check); the independent reproduction's earlier, longer version of it (same class name and fault model) hangs on main (-ea and -da: the independent reproduction, the re-run of 2026-11-03 and the audit; -da: the adjudication), on 10.6.0, 10.5.2 and 9.12.3 (-da, the independent reproduction; 10.6.0 and 9.12.3 again in the history step) and, adapted for the 7.x/8.x test framework, on 7.4.0, 7.7.3, 8.0.0 and 8.11.4 (-da); 7.3.1 passes. The finder's InfoStream-based test hangs on main (-ea, -da), 10.6.0 and 9.12.3 (-da). [read] all 59 release tags from 7.4.0 to 10.6.0 in the release-tag clone have catch (Exception) in this loop and all 64 tags from 4.0.0-beta to 7.3.1 have catch (Throwable); 8.9.0 to 8.11.3 are not in the clone and fall between 8.8.1 (read) and 8.11.4 (exec). Not new in 10.6 (10.5.2 and every executed release from 7.4.0 hang). Not fixed upstream: main 1c2072f0284 (2026-11-02), branch_11x 6c5889ee89f and branch_10_6 5deb5791f6c still catch only Exception there; the last commit on DocumentsWriterFlushControl.java is #16867 (2813efa40a3, author date 2026-10-30), which 5adff78 contains and which leaves these catches alone.
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
non-formal: code reading
Upstream and prior reports
none found (2026-11-03, on top of the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches; rechecked the same day): two scripted prior-report searches (the finder's 25 queries with 20 distinct hits, and the follow-up's 67 queries over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, 170 distinct hits, none high), 54 manual GitHub searches, the changed files of all 409 open apache/lucene PRs, the abortPendingFlushes code on main, branch_11x and branch_10_6, and the commit history of DocumentsWriterFlushControl.java on main (author dates); the same-day recheck found no issue or PR about abortPendingFlushes beyond #6053, no issue or PR on waitForFlush or DocumentsWriterFlushControl updated since 2026-11-01, and none of the 40 PRs updated since 2026-11-01 is about flush aborts. Related history, not reports: #5345 (LUCENE-4277, 2012: created this loop with catch (Throwable) 'keep on aborting the flush queue' to fix a rollback hang in waitForFlush), #6053 (LUCENE-4989, 2013: an exception from dwpt.abort() in this loop skipped doAfterFlush; fixed by the per-entry finally), #9262 (LUCENE-8215, 2018: the introducing change; its discussion is about the new IOUtils helper, not Errors in this loop) and #12654 (2023, fixed: an Error during the tragic close hung IndexWriter at another site).
Lucene commit tested
main 5adff78bb168 (2026-11-02) through gradle in a scratch worktree, with assertions on and off, without and with the recommended fix (the repro, nine IndexWriter suites, checkGoogleJavaFormat), and standalone on the main jars; the fact check also ran it on the 10.6.0 and 9.12.3 jars (hangs). The hunt ran an earlier, longer version of the test (same fault model) on 7.3.1 (pass), 7.4.0, 7.4.0 with the LUCENE-8215 hunk reverted (pass), 7.7.3, 8.0.0, 8.11.4, 9.12.3, 10.5.2 and 10.6.0. Only Linux aarch64 with OpenJDK 25.0.3 was run.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:694-723 (at 5adff78bb168) (abortPendingFlushes: per queued DWPT 'try { subtractFlushedNumDocs; dwpt.abort(); } catch (Exception _) { // that's fine we just abort everything here this is best effort } finally { doAfterFlush(dwpt); }', the same for blockedFlushes; the outer finally at :718-722 clears flushQueue and blockedFlushes), :685-692 (abortFullFlushes -> abortPendingFlushes), :298-301 (doAfterFlush, the only removal from flushingWriters), :361-369 (waitForFlush: untimed wait while flushingWriters is non-empty), :427 and :437-441 (a DWPT enters flushingWriters when it is checked out for flushing, before it is queued)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:98-115 (at 5adff78bb168) (abort: 'now abort' / 'done abort' InfoStream lines around indexingChain.abort(); an Error propagates); lucene/core/src/java/org/apache/lucene/index/StoredFieldsConsumer.java:112-113 (at 5adff78bb168) (abort -> IOUtils.closeWhileHandlingException(writer)); lucene/core/src/java/org/apache/lucene/util/IOUtils.java:155-178 (at 5adff78bb168) (closeWhileHandlingException rethrows Errors: 'we ensure that we bubble up any errors')
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:212-226 (at 5adff78bb168) (abort: abortDocumentsWriterPerThread for the pool, then flushControl.abortPendingFlushes and flushControl.waitForFlush at :226), :727-739 (finishFullFlush(false) -> flushControl.abortFullFlushes)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-165 (at 5adff78bb168) (class javadoc: 'if you hit an Error ... IndexWriter will close itself ... Any subsequent calls will throw an AlreadyClosedException'), :711-722 (getReader: catch Throwable, onTragicEvent for an Error, maybeCloseOnTragicEvent, rethrow), :2630-2636 (rollbackInternalNoCommit: docWriter.abort() at :2635 before deleter.refresh at :2671 and the write.lock release at :2684), :3773-3884 (prepareCommitInternal: finishFullFlush(flushed) at :3875, catch (Error tragedy) at :3879, maybeCloseOnTragicEvent in the finally) and :4383-4411 (doFlush: finishFullFlush(flushSuccess) at :4411); commit and flush reach the same path, by reading
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:586, (at 5adff78bb168) :669-671, :711-722, :2630-2636
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:212-226, (at 5adff78bb168) :494, :577, :727-739
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:298-301, (at 5adff78bb168) :361-369, :427, :437-441, :569-631, :685-723
  • lucene/core/src/java/org/apache/lucene/index/StoredFieldsConsumer.java:112-113; (at 5adff78bb168) IndexingChain.java:530-538; DocumentsWriterPerThread.java:98-115, :585-603; ../util/IOUtils.java:155-178
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-167, (at 5adff78bb168) :1312-1319, :1374-1388, :2575-2583, :2635, :2684, :5860-5870, :6097-6104, :6117-6118, :3773-3884, :4383-4436
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-165, (at 5adff78bb168) :711-722
  • lucene/CHANGES.txt:8741-8743 (at 5adff78bb168) (LUCENE-7570); lucene/core/src/test/org/apache/lucene/index/TestTragicIndexWriterDeadlock.java:101-175; TestIndexWriterOnError.java:59, :234-261
  • lucene/core/src/java/org/apache/lucene/index/../util/IOUtils.java:566-577 (at 5adff78bb168) (applyToAll)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/../util/IOUtils.java:553-577 (at 5adff78bb168) (useOrSuppress, applyToAll)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:585-603 (at 5adff78bb168) (a failed flush fires onTragicEvent)
Diagrams
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:298-301, (at 5adff78bb168) :361-369, :694-723

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

Repro files

About the diagrams

The diagram illustrates the repro's scenario (main 5adff78bb168, DocumentsWriterFlushControl.java:298-301, :361-369, :694-723). The repro rows carry the line numbers of the test file shown in this doc. The flush states drawn are the diagram probe's run with assertions off; with assertions on the segments came in another order, with the same outcome.

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.