All findings · Lucene finding · LFV-846 · flushcontrol-stall-logging-strands-flushing-dwpt

An Error while IndexWriter logs a flush stall strands the buffer being flushed, so the writer hangs instead of closing itself

If IndexWriter's InfoStream throws, for example an OutOfMemoryError while logging "now stalling flushes", just after a full buffer was handed over for flushing, that buffer stays registered as flushing forever. The self-close after the Error, or close() and rollback() after an exception, then wait forever; every release since 6.2.0.

Summary

Priority
Low, rank 795It is a permanent IndexWriter hang in close(), rollback() and the self-close after an Error on core default flush paths since 6.2.0, but it fires only when the opt-in InfoStream throws (in practice an OutOfMemoryError) as a buffer is handed off for flushing.
Impact
hang
Since
6.2.0 (2016, commit 614b45d2dff4)introduced 2016-07-25 · over 10 years
Affected
every release from 6.2.0 through 10.6.0 (11 executed) and main
Status
documented
Found by
semi-formal: a single-fault injection sweep through the InfoStream callback, then code reading

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

Lucene hands a full buffer over for flushing in two steps: it registers the buffer as flushing, then updates the stall state, which can log. If that log call throws, the throw replaces the method's return value: nobody flushes the buffer, but it stays registered, so every wait for running flushes waits forever.

Background

  1. In-memory buffers. IndexWriter collects new documents in per-thread buffers (DWPTs). When the RAM budget is used up, a buffer is marked for flushing and handed over to an indexing thread, which writes it as a segment. The set flushingWriters holds the buffers being written; a buffer leaves it when its flush ends.
  2. Stalling. If the bytes waiting to be flushed exceed twice the RAM budget, new addDocument calls wait until flushing catches up. When an InfoStream is on, Lucene logs "now stalling flushes" and "done stalling flushes" as the state changes.
  3. Waiting for flushes. close(), rollback() and the writer's self-close after an Error first wait until flushingWriters is empty.
  4. InfoStream. An optional debug log, set with IndexWriterConfig.setInfoStream, off by default. Lucene's own tests make it throw to test IndexWriter's error handling.
Diagram of the bug: addDocument fills a buffer past the RAM budget; checkOutForFlush adds the buffer to the set of flushing buffers and takes it out of the pool; then the stall log line "now stalling flushes" throws an OutOfMemoryError, so the buffer is never returned and never flushed; the writer's self-close waits in waitForFlush for the set to become empty, forever. With the stall state updated before the hand-off, the error is thrown before the buffer is registered and the writer closes itself.
The flush hand-off step by step: the buffer is registered as flushing, the stall log line throws, the buffer is never returned or written, and the writer's self-close waits for its flush forever. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Turn on an InfoStream (off by default) and set a 0.001 MB RAM buffer. Add one document with one term: its in-memory buffer takes 68,112 bytes (about 67 KB), over budget, so Lucene registers it as flushing and hands it over to be written.
  2. Right after that hand-off Lucene logs "now stalling flushes: netBytes: 0.1 MB flushBytes: 0.1 MB", because the bytes being flushed (68,112) exceed twice the budget (2,097 bytes) and a stall starts. The InfoStream throws one OutOfMemoryError on that line.
  3. You expect what the IndexWriter javadoc promises: "if you hit an Error ... IndexWriter will close itself". addDocument rethrows the error and isOpen() is false.
  4. Instead the throw replaces the hand-off's return, so nobody ever writes the buffer, yet it stays registered as flushing. The self-close waits for that flush: addDocument is still blocked after 10 s, and only an interrupt ends it.
  5. With an unchecked exception instead of an Error, addDocument throws and the writer stays open, but close() and rollback() then wait forever in the same place.

Who is affected

Applications that turn on IndexWriter's InfoStream (off by default), when a logging call throws during the flush hand-off just as a stall starts: in the test an injected OutOfMemoryError; by reading, an allocation failure while formatting, or a logger that throws. Such a stall needs a buffer over twice the RAM buffer, or flushing behind several threads.

Mechanism, terms and numbers819 words

When a per-thread buffer (DWPT) is full, DocumentsWriterFlushControl.checkOutForFlush hands it over for flushing: it adds it to flushingWriters and checks it out of the pool, and only then, in its finally, calls updateStallState(). That method asks the InfoStream isEnabled("DWFC") and, when the stall state changes, formats and logs "now stalling flushes ..." or "done stalling flushes ...". If that logging throws, the throw replaces return perThread: no caller receives the buffer, nothing flushes or aborts it, and it stays in flushingWriters. waitForFlush() waits, with no timeout, until that set is empty, and it is called by the writer's tragic rollback after an Error, by rollback() and by close(). So after an OutOfMemoryError on the stall message the indexing thread blocks forever inside the self-close the IndexWriter javadoc promises, and after an unchecked exception close() and rollback() hang. It needs the InfoStream, which is off by default, and a logging call that throws just as a stall starts, so it is rare. The logging got into that finally with a 2016 log-noise cleanup (614b45d2dff4, first released in 6.2.0); every release since is affected. Updating the stall state before the hand-off fixes it at the single-buffer hand-offs and changes nothing when no exception is thrown; the full-flush loop in markForFullFlush needs more.

The code (main 645d4fe14a20)

  1. Hand-off first, logging second. DocumentsWriterFlushControl.checkOutForFlush (DocumentsWriterFlushControl.java:409-424): in a try it calls addFlushingDWPT(perThread) (flushingWriters.add, :426-430), numPending-- and perThreadPool.checkout(perThread), then return perThread; its finally calls updateStallState() (:422).
  2. The logging. updateStallState() (:307-348) computes stall = (activeBytes + flushBytes) > limit && activeBytes < limit && !closed (:318, limit = twice the RAM buffer, :106-111). If infoStream.isEnabled("DWFC") (:320) and the stall state changes, it builds a String.format message and calls infoStream.message("DW", ...) (:321-343); only then stallControl.updateStalled(stall) (:346).
  3. A throw loses the only reference. The throw replaces return perThread, so checkout (:268), doAfterDocument (:245) and DocumentsWriter.updateDocuments (DocumentsWriter.java:434) never receive the buffer. It is in flushingWriters and out of the pool. abortPendingFlushes (:681-710) aborts only the flush-queue and blocked entries, and DocumentsWriter.abort (DocumentsWriter.java:218-224) only buffers still in the pool, so nothing removes it; only doAfterFlush (:290-303) would.
  4. Every wait for running flushes waits forever. waitForFlush() (:350-358) is an untimed wait() while flushingWriters is not empty. Only an interrupt ends it.
    • After an Error, IndexWriter.updateDocuments records the tragedy and calls maybeCloseOnTragicEvent() (IndexWriter.java:1632-1639), which runs rollbackInternal() (:6098-6106), then docWriter.abort() (:2635), which waits at DocumentsWriter.java:226.
    • After an unchecked exception, the writer stays open. close() waits in flushAllThreads (DocumentsWriter.java:690), rollback() in abort (:226). If the stall had just started, the stranded bytes keep it on, so the next addDocument loops in DocumentsWriter.preUpdate (DocumentsWriter.java:389-396) on waitIfStalled(). The stall flag itself is set by the second updateStallState() call, in doAfterDocument's finally (:246-248), which the one-shot InfoStream no longer throws from; the hangs in the self-close, close() and rollback() do not depend on it.
  5. The same throw without a hand-off is handled. Thrown from the same updateStallState() call, reached from doAfterDocument's finally when no buffer is checked out, the Error closes the writer and an exception leaves it usable (verifier's control passes with assertions on and off). The hang comes from the order of the hand-off.

The numbers

Minimal test: RAM buffer 0.001 MB, so the stall limit is 2,097 bytes; one document with one term makes a buffer of 68,112 bytes (about 67 KB; the log line rounds it to 0.1 MB). The diagnostic copy of the test captures the line that throws: "now stalling flushes: netBytes: 0.1 MB flushBytes: 0.1 MB fullFlush: false", thrown at updateStallState:323 inside checkOutForFlush:422. addDocument is still blocked after 10 s, in waitForFlush:353 under DocumentsWriter.abort:226 and IndexWriter.maybeCloseOnTragicEvent:6104.

Other sites with the same order

  • doAfterDocument's finally (:246-248) calls updateStallState() after checkout returned a buffer. Verifier made isEnabled throw there: it hangs, also with only checkOutForFlush fixed (executed).
  • markForFullFlush (:580-606), the start of every full flush (commit(), flush(), NRT reopen, close()), checks the pool's buffers out one by one (checkOutForFlush at :590). It keeps them in a local list and moves them into the flush queue only after the loop (:614). A throw inside the loop strands every buffer already checked out, so a full flush can create the stranded state itself (executed by the fact check: two buffers, a live setRAMBufferSizeMB so that the stall starts at the second checkout, one IllegalStateException inside commit(); both buffers stranded, close() hangs).
  • nextPendingFlush polls the flush queue and then calls updateStallState() (:444-448), by reading.
  • checkoutLargestNonPendingWriter has a finally after checkout (:785-790), by reading.
Exposure and likelihood423 words
  • Who. Applications that turn on IndexWriter's InfoStream (IndexWriterConfig.setInfoStream; a new config takes InfoStream.getDefault(), which is NO_OUTPUT unless the application replaces it process-wide with InfoStream.setDefault), for example setInfoStream(System.out) while debugging, or JavaLoggingInfoStream, which MIGRATE.md mentions for logging IndexWriter events through java.util.logging. Servers that forward the InfoStream into their logging belong here while it is on.
  • When. A logging call must throw while a buffer is being handed over for flushing. The "now stalling flushes" line is logged only when a stall starts, and here the stall must start at the hand-off: the bytes being flushed plus the active bytes pass twice the RAM buffer while the active bytes stay below it (DocumentsWriterFlushControl.java:318). With default settings that takes one buffer larger than twice the RAM buffer (a very large document; verifier used one 120,000-term document with a 1 MB buffer), or, by reading, flushing that falls behind several indexing threads. With the default 16 MB buffer and one indexing thread no stall starts: 60,828 small documents and 4 flushes logged no "now stalling flushes" (executed by the fact check). The minimal test's 0.001 MB buffer makes its first buffer (68,112 bytes) pass the 2,097-byte stall limit at once. The test injects the OutOfMemoryError from message(); in production it could be an OutOfMemoryError while Lucene formats the line under heap pressure (by reading, not executed) or a logger that throws. A throw from isEnabled("DWFC") needs no stall, since it is asked on every call, but an isEnabled() that throws is contrived.
  • What breaks. After an Error the indexing call never returns, instead of the writer closing itself, and the writer keeps its write lock while the call is blocked (executed by the fact check: a second IndexWriter on the same directory fails with LockObtainFailedException). After an exception, close(), rollback(), commit() and an NRT reopen hang (the last two executed by the fact check, in flushAllThreads), and if a stall had started, so does every later addDocument. Only an interrupt ends a wait. In the Error case one interrupt ends it and the rollback's cleanup releases the write lock; an interrupted close() falls back to the rollback and waits again, so it needs two interrupts (executed by the fact check).
  • Not shown. Damage to committed data. By reading, the stranded buffer's documents, which were not committed, are never written.
  • How likely. Rare: the InfoStream is off by default, the stock PrintStreamInfoStream does not throw, and no report of this hang was found since 2016.

Minimal repro

One JUnit test, one method, public API only. An InfoStream enabled for the stall messages throws one OutOfMemoryError; a 0.001 MB RAM buffer makes the first one-term document's buffer (68,112 bytes) start a flush and a stall. Because the hang is the bug, addDocument runs on a helper thread and the test waits 10 s for it.

TestErrorInStallMessageClosesWriter.java (one test)77 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 org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.InfoStream;

public class TestErrorInStallMessageClosesWriter extends LuceneTestCase {

  public void testOutOfMemoryErrorWhileLoggingStallClosesWriter() throws Exception {
    OutOfMemoryError oome = new OutOfMemoryError("while logging 'now stalling flushes'");
    InfoStream infoStream =
        new InfoStream() {
          boolean thrown;

          @Override
          public void message(String component, String message) {
            if (thrown == false) {
              thrown = true;
              throw oome;
            }
          }

          @Override
          public boolean isEnabled(String component) {
            return component.equals("DWFC");
          }

          @Override
          public void close() {}
        };
    IndexWriterConfig iwc =
        new IndexWriterConfig()
            .setCodec(TestUtil.getDefaultCodec())
            .setRAMBufferSizeMB(0.001)
            .setInfoStream(infoStream);
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriter w = new IndexWriter(dir, iwc);
      Document doc = new Document();
      doc.add(new TextField("f", "a", Field.Store.NO));
      Thread indexer =
          new Thread(
              () -> {
                try {
                  w.addDocument(doc);
                } catch (Throwable ignored) {
                }
              });
      indexer.start();
      indexer.join(10_000);
      // expected: the Error closes the writer and addDocument returns; actual: addDocument hangs
      assertFalse("addDocument should rethrow the Error, but is still blocked", indexer.isAlive());
      assertSame(oome, w.getTragicException());
      assertFalse(w.isOpen());
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestErrorInStallMessageClosesWriter -Ptests.seed=DEADBEEF
Expected
addDocument rethrows the OutOfMemoryError, getTragicException() is that error and isOpen() is false.
Observed
addDocument is still blocked after 10 s, waiting in waitForFlush inside the writer's own rollback, with assertions on and off.

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

More files, environment, full output311 words

The test sits in org.apache.lucene.index only for placement next to TestIndexWriterExceptions; it uses public API only (IndexWriterConfig, InfoStream, ByteBuffersDirectory) and pins the default codec. It throws from message() on the stall message, not from isEnabled(). By reading, a real allocation failure while Lucene formats that message would be thrown from the same statement (DocumentsWriterFlushControl.java:323-330); that variant was not executed.

On main the blocked thread is still there when the test ends, so the test runner adds its standard suite-level ThreadLeakError: not a second symptom, it prints the blocked thread's stack (the waitForFlush location) and then interrupts it, which ends the thread.

Regression test: this same test. It passes with either fix and fits in TestIndexWriterExceptions next to testOutOfMemoryErrorCausesCloseToFail.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with either fix: passes. 10.6.0 release jars: fails
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
cp $REPRO/TestErrorInStallMessageClosesWriter.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestErrorInStallMessageClosesWriter -Ptests.seed=DEADBEEF
# the same result with -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestErrorInStallMessageClosesWriter > testOutOfMemoryErrorWhileLoggingStallClosesWriter FAILED
    java.lang.AssertionError: addDocument should rethrow the Error, but is still blocked
        at org.apache.lucene.index.TestErrorInStallMessageClosesWriter.testOutOfMemoryErrorWhileLoggingStallClosesWriter(TestErrorInStallMessageClosesWriter.java:72)

TestErrorInStallMessageClosesWriter > executionError FAILED
    com.carrotsearch.randomizedtesting.ThreadLeakError: 1 thread leaked from SUITE scope at org.apache.lucene.index.TestErrorInStallMessageClosesWriter:
       1) Thread[id=21, name=Thread-3, state=WAITING, group=TGRP-TestErrorInStallMessageClosesWriter]
            at java.base/java.lang.Object.wait(Object.java:351)
            at org.apache.lucene.index.DocumentsWriterFlushControl.waitForFlush(DocumentsWriterFlushControl.java:353)
            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:6104)
            at org.apache.lucene.index.IndexWriter.updateDocuments(IndexWriter.java:1639)
            at org.apache.lucene.index.IndexWriter.updateDocument(IndexWriter.java:1913)
            at org.apache.lucene.index.IndexWriter.addDocument(IndexWriter.java:1492)
2 tests completed, 2 failed

# where it throws (a diagnostic copy that prints the stack inside message(), main, assertions on)
message = now stalling flushes: netBytes: 0.1 MB flushBytes: 0.1 MB fullFlush: false
  at DocumentsWriterFlushControl.updateStallState(DocumentsWriterFlushControl.java:323)
  at DocumentsWriterFlushControl.checkOutForFlush(DocumentsWriterFlushControl.java:422)
  at DocumentsWriterFlushControl.checkout(DocumentsWriterFlushControl.java:268)
  at DocumentsWriterFlushControl.doAfterDocument(DocumentsWriterFlushControl.java:245)

# 10.6.0 release jars, assertions off: the same failure (waitForFlush:351 <- DocumentsWriter.abort:227)
# with either fix: 1 test, 0 failures, assertions on and off

Why it looks unintended

  • The IndexWriter javadoc promises "if you hit an Error ... IndexWriter will close itself"; here that self-close never returns.
  • Lucene's own tests throw errors from InfoStream.message at other sites and expect recovery; the same throw with no buffer being handed over is handled.
  • The log call landed after the hand-off in a 2016 log-noise cleanup, not by design.
The full argument319 words
  • The contract. IndexWriter class javadoc (IndexWriter.java:162-165): "if you hit an Error, or disaster strikes during a checkpoint then IndexWriter will close itself ... Any subsequent calls will throw an AlreadyClosedException." The thread that hit the Error blocks inside that self-close forever. That is the strong contract. The close() and rollback() javadocs (:1341-1361, :2566-2574) promise no time bound (close() "may be a costly operation"), but neither describes an indefinite wait.
  • Maintainers name the self-close as the defense against OOM. rmuir on #13275 (2024-04-05): "This is why indexwriter closes itself on oom." On #13277 ("IndexWriter: Treat java.lang.Error as tragedy", merged 2024-04-24): "user can plug into low-level lucene apis such as Directory, Codec, etc, but the same rules apply".
  • Lucene's tests inject this kind of fault and expect recovery, at other sites. In TestIndexWriterExceptions, testOutOfMemoryErrorCausesCloseToFail (LUCENE-1429, :1110-1147) and testOutOfMemoryErrorRollback (:1149-1200) throw OutOfMemoryError from InfoStream.message on "now flush at close" and "startFullFlush"; testExceptionDuringRollback (:1980-2052) and testOnlyRollbackOnceOnException (:2195-2235) throw RuntimeException from it during rollback. None throws at this hand-off. IndexWriter.onTragicEvent guards its own logging with catch (Throwable t) { tragedy.addSuppressed(t); } (IndexWriter.java:6075-6083).
  • The same throw elsewhere is handled. Verifier's control throws the same isEnabled("DWFC") from the same updateStallState() call when no buffer is being handed over: the Error closes the writer, an exception leaves it usable (pass, assertions on and off).
  • How the call got there. 614b45d2dff4 (2016, "reduce IW.infoStream noise when stalling ...") moved the stall logging from the waiting thread, which holds no buffer (LUCENE-6209), into updateStallState(), which the hand-off already called in its finally.
  • Precedent. #5345 (LUCENE-4277, 2012): a flush-pending buffer left behind in the flush queue (by a full-flush race) made abort hang in waitForFlush; fixed as a bug by pruning such buffers in abort. #6053 (LUCENE-4989, 2013): an exception that skipped doAfterFlush; fixed with a finally.
The devil's-advocate check248 words

The refute reviewer and the intended-lens reviewer (both OpenAI GPT-5.6, reading only) built the case for "not a bug": InfoStream is a @lucene.internal "Debugging API" (InfoStream.java:22-29) with no promise that IndexWriter survives a throwing implementation; an isEnabled() that throws is contrived, since the stock implementations never do; and rmuir on #13275 said "Can't make java code more complicated to deal with issues like this, if the jvm is broke then it's broke." The refute reviewer concluded not real (medium); the intended-lens reviewer rated the argument moderate and its verdict uncertain (low). A maintainer may also cite rmuir's #13277 comment (2024-04-11), whose bullets include "these are Errors IndexWriter is hitting from lucene apis, not application code", since an InfoStream is application code; the next bullet ("user can plug into low-level lucene apis such as Directory, Codec, etc, but the same rules apply") partly answers it.

Why it fails: the minimal test throws from message() on Lucene's own String.format line, not from isEnabled(); nothing documents that an InfoStream must not throw, and setInfoStream is public; the same #13275 thread names the self-close as the defense against OOM, and the self-close is what hangs; Lucene's tests inject the same kind of fault at other InfoStream sites and expect recovery; and the fix only reorders existing calls. The intended-lens reviewer listed most of these points itself. The unchecked-exception variant is the weaker half; maintainers may call it hardening. Verdict: not intended, a defect (adjudication: confirmed, medium confidence).

Suggested fix

Recommended Update the stall state before every hand-off, and ask the InfoStream only on a change

Update the stall state, which may log, before a buffer is handed over, in checkOutForFlush and nextPendingFlush, and ask the InfoStream only when the stall state changes. Behaviour without a throw is the same. Validated: the repro and 230 tests in 10 closest suites pass. It leaves the full-flush loop in markForFullFlush open (executed).

    // Update the stall state (which may log) before the hand-off: the hand-off does not change
    // activeBytes or flushBytes, and a throw from the InfoStream must not strand a DWPT that is
    // already in flushingWriters but was never returned to a caller.
    updateStallState();
    addFlushingDWPT(perThread);
    numPending--; // write access synced
    boolean checkedOut = perThreadPool.checkout(perThread);
    assert checkedOut;
    return perThread;
All options and trade-offs4 options ยท 934 words

Two fixes were validated in a scratch worktree of 645d4fe14a20. Both move the logging ahead of the hand-off. That is equivalent when nothing throws: the hand-off changes none of the inputs of the stall decision (activeBytes, flushBytes, closed). Then a throw from the InfoStream leaves the buffer being handed off flush-pending in the pool, where the rollback or the next flush finds it. Neither covers the full-flush loop in markForFullFlush (DocumentsWriterFlushControl.java:580-606): a throw at a later buffer's stall update, now before its hand-off, still strands the buffers that earlier iterations already handed off into the loop's local list, which are in flushingWriters but not yet in the flush queue. Executed by the fact check with the recommended fix shadowed: after commit() threw, one buffer stayed in the pool, the other was stranded, and close() still hung in waitForFlush. A possible extension, not validated: queue each checked-out buffer right after its checkout, or update the stall state once after flushQueue.addAll (:614-615) instead of inside the loop. Two other directions are listed but were not run.

Update the stall state before every hand-off, and ask the InfoStream only on a change (validated by the checks) (recommended)

One file, DocumentsWriterFlushControl.java, +15/-15 (fix-handoff.diff): (1) checkOutForFlush calls updateStallState() first and then hands the buffer over, with no try/finally; (2) nextPendingFlush calls it before flushQueue.poll() instead of after; (3) updateStallState() tests stall != stallControl.anyStalledThreads() (a volatile read) before infoStream.isEnabled("DWFC"). With (1) and (2), a stall change caused by a hand-off is logged before the buffer is registered; with (3), the later calls in the finally blocks of doAfterDocument and checkoutLargestNonPendingWriter make no InfoStream call after a hand-off that succeeded and already updated the state under the same lock (they still do when the state did change, e.g. after the pre-hand-off update threw).

    // Update the stall state (which may log) before the hand-off: the hand-off does not change
    // activeBytes or flushBytes, and a throw from the InfoStream must not strand a DWPT that is
    // already in flushingWriters but was never returned to a caller.
    updateStallState();
    addFlushingDWPT(perThread);
    numPending--; // write access synced
    boolean checkedOut = perThreadPool.checkout(perThread);
    assert checkedOut;
    return perThread;

Trade-offs

Three small edits instead of one, all in one class; no lock, no new state, and one InfoStream call fewer per updateStallState() when nothing changes. Edits (2) and (3) are justified by reading: no executed test throws at nextPendingFlush (reached during a full flush) or at the isEnabled call in doAfterDocument's finally under this fix (verifier's test for the latter needs isEnabled to be called there, which the fix removes). Not covered: markForFullFlush's loop (executed by the fact check: close() still hangs). The fix does not make IndexWriter tolerate an InfoStream that always throws; it only keeps a throw from stranding a buffer at the single-buffer hand-offs. Not examined: a throw from the "done stalling flushes" line in doAfterFlush, where the buffer has already left flushingWriters. Applies to upstream main 2f424fd428da with patch (offset 11 lines, one hunk with fuzz 2; dry run recorded).

What the checks validated

  • Stock: the minimal test fails with assertions on and off.
  • With the fix: the minimal test passes with assertions on and off; addDocument rethrows the Error and the writer is closed.
  • Closest suites with the fix (assertions on, seed DEADBEEF): TestIndexWriterExceptions (40 tests, including the InfoStream OutOfMemoryError tests), TestIndexWriterExceptions2, TestFlushByRamOrCountsPolicy, TestDocumentsWriterStallControl, TestIndexWriterWithThreads, TestIndexWriterThreadsToSegments, TestIndexWriter, TestInfoStream, TestIndexWriterDelete, TestIndexWriterCommit: 10 classes, 230 tests, 0 failures, 13 skipped.
  • :lucene:core:checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain pass.
  • Verifier's 7-test class with this fix (standalone runner, the patched class shadowed over the main jars, assertions off): the four stall-message tests (the Error closes the writer; close(), rollback() and the next addDocument return after an exception) and the no-throw control pass. Its two isEnabled tests fail only their precondition "isEnabled was reached in checkOutForFlush", because the fix no longer asks the InfoStream there unless the stall state changes.
  • Not run: nightly tests, benchmarks.

Only reorder checkOutForFlush (verifier's patch) (validated by the checks)

Edit (1) alone, +9/-9 (fix-move-checkout.diff): updateStallState() before addFlushingDWPT and the checkout in checkOutForFlush.

Trade-offs

Smallest change and covers the executed trigger at the hand-off, but not the other sites: with it, an isEnabled throw in doAfterDocument's finally after the checkout still hangs (executed by the independent reproduction), and nextPendingFlush's poll-then-update keeps the same order (by reading). Like the recommended fix, it leaves markForFullFlush's loop open (the loop calls the same checkOutForFlush).

What the checks validated

  • The minimal test passes with assertions on and off (same gradle run; closest suites not re-run for this option).
  • Earlier: the independent reproduction's 7-test class passes 7 of 7 on main a9779ef3c193 with this patch (the Errors close the writer; close(), rollback() and the next addDocument return after the exceptions).

Undo the hand-off on a throw (not run)

Keep the order, but catch a throw after the checkout and give the buffer back: remove it from flushingWriters (or abort it through doAfterFlush), notify waiters, then rethrow.

Trade-offs

More code on the indexing hot path, and every caller that receives a buffer and then calls updateStallState() needs it, markForFullFlush's loop included; at the single-buffer hand-offs the reorder reaches the same state with less code.

Not recommended: catch Throwable around the stall logging

Make updateStallState() ignore failures of its own log calls.

Trade-offs

It would hide an OutOfMemoryError, while IndexWriter's policy (#13277) is to treat an Error as a tragedy and close the writer.

Downstream note

Servers that forward IndexWriter's InfoStream into their own logging are the affected group: after an out-of-memory error at a flush stall, the indexing thread blocks forever instead of the writer closing itself.

How it was found

A fault-injection sweep through the public InfoStream callback threw one OutOfMemoryError at each of the 1,052 isEnabled calls of a 24-operation indexing script, one call per run; 12 runs hung at this call. Code reading found the cause, and the test was cut down to one document and one throw from message().

The method in full136 words

Method: bounded-exhaustive single-fault injection, then code reading (the hunt's "semi-formal" tier).

  • A hunt unit studies error handlers and errors raised through public callbacks. Its sweep ran the real lucene-core jar with a 24-operation script and SerialMergeScheduler, and threw one OutOfMemoryError at the k-th InfoStream.isEnabled call, for every k of the 1,052 calls.
  • 12 runs, all at DocumentsWriterFlushControl.updateStallState, hung in DocumentsWriter.abort -> waitForFlush during the tragic rollback. The sweep's message() pass could not reach the site, because the stall message is logged only when the stall state changes.
  • Code reading explained the hang (the hand-off registers the buffer before the logging call) and gave a 2-test public-API JUnit. A verifier then wrote an independent test that throws from message() on the stall line, and the minimal test keeps only that Error case with one document.

Prior reports, history and checks

Prior reports

No prior report found: 3 runs of the hunt's search tool (GitHub, mailing lists, JIRA archive, downstream projects), 58 manual GitHub searches, 10 web searches and the files of all 411 open PRs, 2026-10-28; re-checked on GitHub 2026-10-31. Closest: #5345 (LUCENE-4277), the same hang from a different cause, fixed in 2012.

Searches5 searches

No issue, PR, commit, JIRA entry, list thread or downstream report names this cause. The closest items are fixed bugs of the same "flushing buffer left registered after an exception" family at other sites: #5345 (LUCENE-4277, 2012, before the 2016 logging change that added this site) and #6053 (LUCENE-4989, 2013).

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search, 3 runs (identifiers, symptoms, mechanism)2026-10-28each run: GitHub issues, PRs and commits; Elasticsearch, OpenSearch and Solr; the dev@, java-user@, issues@ and commits@ lists; the JIRA archiveno report of this defect
GitHub (apache/lucene), manual2026-10-2858 queries: checkOutForFlush, updateStallState, waitForFlush, flushingWriters, addFlushingDWPT, "now stalling flushes", hang / OOM / InfoStream phrasings, the last-48-hour legsrelated fixed bugs only: #5345 (LUCENE-4277), #6053 (LUCENE-4989), #12654 / #12751, #6066; #7271 (LUCENE-6209) added the stall messages
Open PRs2026-10-28the changed files of all 411 open apache/lucene PRs#16867, #16847, #15330, #13409 and #14837 touch these files, none the hand-off; #16867 was merged 2026-10-30 and leaves the hand-off as it is
Web2026-10-2810 queries (identifiers, stack frames, symptoms, downstream)nothing relevant
GitHub re-check2026-10-31checkOutForFlush, updateStallState, "now stalling flushes", flushingWriters, waitForFlush hang, "InfoStream throws", "InfoStream OutOfMemoryError"; every issue and PR created since 2026-10-28 (recorded)nothing new

History

Introduced by 614b45d2dff4 (2016-07-25), a log-noise cleanup that moved the stall logging into updateStallState(); first released in 6.2.0 through its branch_6x backport. A shadow pair isolates the commit; 11 releases from 6.2.0 to 10.6.0 and main fail, 5.5.5 and 6.1.0 pass.

Versions tested6 rows

Introduced by 614b45d2dff43cb05df1f4fa65a40d0568bda616 (Mike McCandless, 2016-07-25, "reduce IW.infoStream noise when stalling happens due to too many total bytes flushing; only notifyAll in stall/unstall when it changes"; a direct commit, no JIRA key or PR), backported to branch_6x the same day as 30db9e72ba3a, which shipped in 6.2.0. It moved the stall logging from DocumentsWriterStallControl (LUCENE-6209, 2015, logged by the waiting thread, which holds no buffer) into updateStallState(). The hand-off had called updateStallState() in its finally since LUCENE-3090 (2011) and LUCENE-4158 (2012); that was harmless while the method called no InfoStream method.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails with assertions on and off (gradle and the standalone runner); passes with either fix
10.6.0 affectedthe minimal test fails on the release jars (assertions off)
6.2.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, main a9779ef3c193 affectedthe version-neutral probe hangs in all four fault scenarios (Error and exception, from message() and from isEnabled())
5.5.5, 6.1.0 not affectedno InfoStream call inside the hand-off; the probe's throws never hit it
6.1.0 + only the 2016 logging hunk / 6.2.0 - only that hunk shadow pair6.1.0 with the hunk hangs; 6.2.0 without it does not
upstream main 2f424fd428da (2026-10-30) readthe same hand-off and logging; #16867 changed the stall condition only

Independent checks

Verifiers, cross-model reviews and an adjudication checked the candidate: every executed check reproduced the hang; the reviewers from another model family agreed on the cause and disputed only whether a throwing InfoStream is in contract. The adjudication confirmed it with medium confidence.

Checks and verdicts397 words

Each check ran or read independently; the table lists what each one executed.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateSweep: 12 single-OOM runs hung at updateStallState; a 2-test JUnit fails on main, 10.5.2, 9.12.3, 8.11.4 and 8.0.0.
Refute review (OpenAI GPT-5.6, reading only) not real (medium), root cause agreesContract argument only (@lucene.internal); a separate re-run of the finder's test failed as claimed.
Independent reproducer real (low)Own 7-test class: 6 of 7 fail on main (assertions on and off), 10.5.2 and 9.12.3, the no-throw control passes; a shadow fix passes 7 of 7; found the waitIfStalled follow-on and the throw point in doAfterDocument's finally.
Intended-lens review (OpenAI GPT-5.6, reading only) uncertain (low), root cause agreesRated the "internal debugging hook" case moderate and weakened it itself; the cross-model review's own message()-only test fails on main and 10.5.2.
Intended-behaviour devil's advocate real (medium-high for the Error, medium for the exception)Control: the same throw with no hand-off is handled correctly (passes with assertions on and off).
Adjudication confirmed (medium), hangRe-ran the independent reproduction's class with new seeds: 6 of 7 fail, the control passes.
History follow-up history correctedFirst affected release 6.2.0 (not 7.0.0, and not LUCENE-5006); the shadow pair isolates 614b45d2dff4.
Registration check and credibility audit keepThe independent reproduction's class re-run with two more seeds, assertions off and on: 6 of 7 fail, the control passes.
Minimal repro and this brief real; fixes validatedThe one-test file fails on main with assertions on and off and on the 10.6.0 jars; both fixes pass it in a gradle scratch worktree.
Fact check (executed) real; two text correctionsRe-ran the minimal test (standalone and gradle, assertions on and off, stock and both fixes), the 10.6.0 jars, the independent reproduction's and the intended-behaviour check's classes and four probes. Found the markForFullFlush gap in both fixes and that the stall-message trigger needs a stall to start at the hand-off; both are now in this doc.
Fact check (OpenAI GPT-5.6, reading only) real; wording correctionsChecked the brief against the code and the recorded runs; raised the same markForFullFlush gap and several wording points (the injected vs. a real formatting OOME, the default InfoStream, the git log -S scope), all adopted.

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

Finding record

LFV-846, snapshot of 2026-11-06

Registry title
If IndexWriter's InfoStream throws while a full in-memory buffer is handed over for flushing, that buffer stays registered as flushing forever, so the writer hangs in its own self-close after an Error, or in close() and rollback() after an exception (every release since 6.2.0)
Area
index / IndexWriter flush hand-off (DocumentsWriterFlushControl.checkOutForFlush, updateStallState, waitForFlush)
Priority
Low, rank 795: It is a permanent IndexWriter hang in close(), rollback() and the self-close after an Error on core default flush paths since 6.2.0, but it fires only when the opt-in InfoStream throws (in practice an OutOfMemoryError) as a buffer is handed off for flushing.
Impact class
hang
Impact note
This hits applications that turn on IndexWriter's InfoStream (off by default) when the InfoStream throws during the flush hand-off; realistically that is an OutOfMemoryError while Lucene formats its own 'now stalling flushes' message at the moment indexing outruns flushing, or an exception from a logging bridge. After an Error the indexing call never returns, because the writer's self-close waits forever in DocumentsWriter.abort -> waitForFlush; after an unchecked exception the writer stays open, but close() and rollback() wait forever, and if a stall had started the next addDocument also waits forever in waitIfStalled. No scale is needed and nothing else must be non-default (the repros' small RAM buffer or maxBufferedDocs=2 only make the flush happen sooner); every release from 6.2.0 to 10.5.2 and main is affected [exec 6.2.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, main], and 10.6.0 by reading.
Summary
You index with IndexWriter's InfoStream turned on, and one logging call throws (an OutOfMemoryError while Lucene builds its 'now stalling flushes' message, or an exception from the logging backend) just as a full in-memory buffer is handed over to be flushed. You expect what the IndexWriter javadoc promises: after an Error the writer closes itself, and after an exception close() or rollback() still finish. Instead the buffer is already registered as 'flushing' when the log call throws, nothing ever flushes or releases it, and every later wait for running flushes waits forever: after an Error addDocument hangs inside the writer's own rollback, and after an exception close() and rollback() hang (and, by code reading, commit() and an NRT reopen too).
Introduced
614b45d2dff43cb05df1f4fa65a40d0568bda616 (mikemccand, 'reduce IW.infoStream noise when stalling happens due to too many total bytes flushing; only notifyAll in stall/unstall when it changes'; a direct commit with no JIRA key or PR; branch_6x backport 30db9e72ba3a with identical Java hunks). It moved the stall logging (infoStream.isEnabled("DWFC") plus a String.format'd 'now stalling flushes' / 'done stalling flushes' message) from DocumentsWriterStallControl, where the waiting thread logged it with no buffer checked out (LUCENE-6209, 669e9cf617c, 2015), into DocumentsWriterFlushControl.updateStallState(). The hand-off already called updateStallState() in its finally, after adding the buffer to flushingWriters (try/finally from LUCENE-3090, 4fd5dceb998b, 2011; the call from LUCENE-4158, c4e4b36037a3, 2012; harmless until then because updateStallState called no InfoStream method). [exec: 6.1.0 plus only this updateStallState logging hunk hangs, and 6.2.0 minus only this hunk does not; git log -S 'now stalling flushes' lists only 614b45d2dff and 669e9cf617c] (2016-07-25 (author date of 614b45d2dff4 and of the branch_6x backport 30db9e72ba3a))
First affected version
6.2.0 [exec: the history probe hangs in all four fault scenarios and the candidate JUnit hangs in both tests]. 6.1.0 and 5.5.5 are not affected [exec]. 6.2.0 shipped the branch_6x backport 30db9e72ba3a; the master commit 614b45d2dff4 first shipped in 7.0.0.
Affected versions
main a9779ef3c193 [exec -ea and -da]. 6.2.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 [exec: history probe FlushHandOffHistoryProbe, all four scenarios hang; the candidate JUnit also fails on 6.2.0, 8.0.0, 8.11.4, 9.12.3 and 10.5.2, and the independent reproduction's JUnit fails 6 of 7 with its control passing on 9.12.3 and 10.5.2]. Every release tag from 6.2.0 to 10.5.2 has the InfoStream call in updateStallState [read: tag scan]; the unreleased branch_10_5 and branch_10x heads and the branch_10_6 head have the same hand-off and logging [read], and DocumentsWriterFlushControl.java is the 10.5.2 file at the 10.6.0 commit 4ae946e5a9b (released 2026-10-29), so 10.6.0 is affected by reading; upstream main e1faeba08bd [exec 2026-11-02, -ea and -da]: since 10.6.0, DocumentsWriterFlushControl.java was changed only by #16867 (2813efa40a3, 2026-10-30), outside checkOutForFlush; branch_11x da3ea39fa59 is the same as main and branch_10_6 91eaab4fb29 the same as 10.6.0 [read]. Not affected: 6.1.0 and 5.5.5 [exec], 4.1.0-6.1.0 [read]. All runs on aarch64 with JDK 25
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check; also reached by code reading
Upstream and prior reports
none found (the prior-report record verdict new, searched 2026-10-28, read-only). No issue, PR, commit, JIRA entry, mailing-list thread, downstream report or web page names this root cause: checkOutForFlush registers the buffer in flushingWriters and checks it out of the pool, and only then, in its finally, calls updateStallState(), whose InfoStream calls can throw and discard the checked-out buffer. Searches: 3 scripted prior-report searches (identifiers, symptoms, mechanism; each over GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, and JIRA), 58 manual GitHub searches including the last-48-hour legs, 10 web queries, the changed files of all 411 open apache/lucene PRs, and git history. Same failure family at other sites, all fixed and with other causes: #5345 (LUCENE-4277, 2012: a flush-pending buffer left in the flush queue by a full-flush race, the same waitForFlush hang in abort, fixed by pruning such buffers in abort; it predates the 2016 stall logging), #6053 (LUCENE-4989, 2013: an exception in abortPendingFlushes skipped doAfterFlush, fixed with a finally), #12654 / #12751 (2023: an Error inside rollbackInternal left 'closing' set). #16867 (merged 2026-10-30 as 2813efa40a3 on main and cdfe8aa7829 on branch_11x; not on branch_10_6) and the still-open PRs #16847, #15330, #13409 and #14837 touch DocumentsWriterFlushControl.java or DocumentsWriter.java but not checkOutForFlush or its finally (#16867 adds updateStallState() calls in doOnDelete and in the finally of abortFullFlushes).
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner; with either fix it passes, and with the recommended fix 230 tests in 10 closest suites pass (0 failures, 13 skipped). The 10.6.0 release jars fail. A version-neutral probe hangs on 6.2.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main a9779ef3c193 and passes on 5.5.5 and 6.1.0. Upstream main 2f424fd428da has the same hand-off (read, 2026-10-31).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:409-424 (at a9779ef3c193) (checkOutForFlush: addFlushingDWPT, numPending-- and perThreadPool.checkout in the try, 'return perThread', updateStallState() in the finally), :426-430 (addFlushingDWPT: flushingWriters.add), :307-348 (updateStallState: infoStream.isEnabled("DWFC") at :320, String.format'd 'now stalling flushes' / 'done stalling flushes' messages at :321-343 when the stall state changes), :350-358 (waitForFlush: untimed wait while flushingWriters is not empty), :246-248 (doAfterDocument's finally calls updateStallState again), :446-448 (nextPendingFlush: flushQueue.poll() then updateStallState())
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:212-227 (at a9779ef3c193) (abort: abortPendingFlushes, then flushControl.waitForFlush at :226), :684-690 (flushAllThreads: waitForFlush at :690)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-165 (at a9779ef3c193) (class javadoc: 'if you hit an Error ... IndexWriter will close itself'); lucene/core/src/java/org/apache/lucene/util/InfoStream.java:22-29 ('Debugging API', @lucene.internal); lucene/core/src/test/org/apache/lucene/index/TestIndexWriterExceptions.java:1111-1191, 1980-2012, 2195-2230 (upstream tests that throw OutOfMemoryError or RuntimeException from InfoStream.message and expect recovery)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:106-111, (at 645d4fe14a20) :245-248, :268, :290-303, :307-348 (:318, :320, :321-343, :346), :350-358, :409-424, :426-430, :444-448, :681-710, :785-790
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:212-227 (at 645d4fe14a20) (:218-224, :226), :389-396, :434, :667-690; IndexWriter.java:1632-1639, :2601-2636, :6098-6106
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:580-606 (at 645d4fe14a20) (:590), :614-615 (markForFullFlush)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:430-446 (at 645d4fe14a20) (setInfoStream); lucene/core/src/java/org/apache/lucene/util/PrintStreamInfoStream.java:60-62; lucene/MIGRATE.md:1133-1134; IndexWriter.java:2684
  • lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:142; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/InfoStream.java:56-80 (getDefault, setDefault); DocumentsWriterFlushControl.java:318; IndexWriter.java:1328-1335, :2686-2710
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-165, (at 645d4fe14a20) :1341-1361, :2566-2574, :6075-6083; lucene/core/src/java/org/apache/lucene/util/InfoStream.java:22-29
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterExceptions.java:1110-1147, (at 645d4fe14a20) :1149-1200, :1980-2052, :2195-2235
  • GitHub apache/lucene #13275 (rmuir comments, 2024-04-05) and #13277 (rmuir comment of 2024-04-11, all bullets; merged 2024-04-24), read 2026-10-31
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:246-248, (at 645d4fe14a20) :307-348, :409-424, :444-448, :785-790
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:580-606, (at 645d4fe14a20) :614-615; DocumentsWriterStallControl.java:38
Prior reports
  • GitHub commit log of DocumentsWriterFlushControl.java, read 2026-10-31 (this brief)
History
  • GitHub apache/lucene commits for DocumentsWriterFlushControl.java and its content at 2f424fd428da (read 2026-10-31)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the executed minimal test.

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.