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.
- In-memory buffers.
IndexWritercollects 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 setflushingWritersholds the buffers being written; a buffer leaves it when its flush ends. - Stalling. If the bytes waiting to be flushed exceed twice the RAM budget, new
addDocumentcalls wait until flushing catches up. When an InfoStream is on, Lucene logs "now stalling flushes" and "done stalling flushes" as the state changes. - Waiting for flushes.
close(),rollback()and the writer's self-close after an Error first wait untilflushingWritersis empty. - 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.
- 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.
- 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.
- You expect what the IndexWriter javadoc promises: "if you hit an Error ... IndexWriter will close itself".
addDocumentrethrows the error andisOpen()is false. - 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:addDocumentis still blocked after 10 s, and only an interrupt ends it. - With an unchecked exception instead of an Error,
addDocumentthrows and the writer stays open, butclose()androllback()then wait forever in the same place.
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.
- Hand-off first, logging second.
DocumentsWriterFlushControl.checkOutForFlush(DocumentsWriterFlushControl.java:409-424): in atryit callsaddFlushingDWPT(perThread)(flushingWriters.add,:426-430),numPending--andperThreadPool.checkout(perThread), thenreturn perThread; itsfinallycallsupdateStallState()(:422). - The logging.
updateStallState()(:307-348) computesstall = (activeBytes + flushBytes) > limit && activeBytes < limit && !closed(:318,limit= twice the RAM buffer,:106-111). IfinfoStream.isEnabled("DWFC")(:320) and the stall state changes, it builds aString.formatmessage and callsinfoStream.message("DW", ...)(:321-343); only thenstallControl.updateStalled(stall)(:346). - A throw loses the only reference. The throw replaces
return perThread, socheckout(:268),doAfterDocument(:245) andDocumentsWriter.updateDocuments(DocumentsWriter.java:434) never receive the buffer. It is influshingWritersand out of the pool.abortPendingFlushes(:681-710) aborts only the flush-queue and blocked entries, andDocumentsWriter.abort(DocumentsWriter.java:218-224) only buffers still in the pool, so nothing removes it; onlydoAfterFlush(:290-303) would. - Every wait for running flushes waits forever.
waitForFlush()(:350-358) is an untimedwait()whileflushingWritersis not empty. Only an interrupt ends it.- After an Error,
IndexWriter.updateDocumentsrecords the tragedy and callsmaybeCloseOnTragicEvent()(IndexWriter.java:1632-1639), which runsrollbackInternal()(:6098-6106), thendocWriter.abort()(:2635), which waits atDocumentsWriter.java:226. - After an unchecked exception, the writer stays open.
close()waits influshAllThreads(DocumentsWriter.java:690),rollback()inabort(:226). If the stall had just started, the stranded bytes keep it on, so the nextaddDocumentloops inDocumentsWriter.preUpdate(DocumentsWriter.java:389-396) onwaitIfStalled(). The stall flag itself is set by the secondupdateStallState()call, indoAfterDocument'sfinally(:246-248), which the one-shot InfoStream no longer throws from; the hangs in the self-close,close()androllback()do not depend on it.
- After an Error,
- The same throw without a hand-off is handled. Thrown from the same
updateStallState()call, reached fromdoAfterDocument'sfinallywhen 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.
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.
doAfterDocument'sfinally(:246-248) callsupdateStallState()aftercheckoutreturned a buffer. Verifier madeisEnabledthrow there: it hangs, also with onlycheckOutForFlushfixed (executed).markForFullFlush(:580-606), the start of every full flush (commit(),flush(), NRT reopen,close()), checks the pool's buffers out one by one (checkOutForFlushat: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 livesetRAMBufferSizeMBso that the stall starts at the second checkout, oneIllegalStateExceptioninsidecommit(); both buffers stranded,close()hangs).nextPendingFlushpolls the flush queue and then callsupdateStallState()(:444-448), by reading.checkoutLargestNonPendingWriterhas afinallyaftercheckout(:785-790), by reading.
Exposure and likelihood423 words
- Who. Applications that turn on IndexWriter's InfoStream (
IndexWriterConfig.setInfoStream; a new config takesInfoStream.getDefault(), which isNO_OUTPUTunless the application replaces it process-wide withInfoStream.setDefault), for examplesetInfoStream(System.out)while debugging, orJavaLoggingInfoStream, whichMIGRATE.mdmentions 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 theOutOfMemoryErrorfrommessage(); in production it could be anOutOfMemoryErrorwhile Lucene formats the line under heap pressure (by reading, not executed) or a logger that throws. A throw fromisEnabled("DWFC")needs no stall, since it is asked on every call, but anisEnabled()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
IndexWriteron the same directory fails withLockObtainFailedException). After an exception,close(),rollback(),commit()and an NRT reopen hang (the last two executed by the fact check, influshAllThreads), and if a stall had started, so does every lateraddDocument. Only an interrupt ends a wait. In the Error case one interrupt ends it and the rollback's cleanup releases the write lock; an interruptedclose()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
PrintStreamInfoStreamdoes not throw, and no report of this hang was found since 2016.
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.
/*
* 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());
}
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests TestErrorInStallMessageClosesWriter -Ptests.seed=DEADBEEF
- Expected
addDocumentrethrows the OutOfMemoryError,getTragicException()is that error andisOpen()is false.- Observed
addDocumentis still blocked after 10 s, waiting inwaitForFlushinside the writer's own rollback, with assertions on and off.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with either fix: passes. 10.6.0 release jars: fails |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
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
# 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- 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.messageat 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.
IndexWriterclass 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. Theclose()androllback()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) andtestOutOfMemoryErrorRollback(:1149-1200) throwOutOfMemoryErrorfromInfoStream.messageon "now flush at close" and "startFullFlush";testExceptionDuringRollback(:1980-2052) andtestOnlyRollbackOnceOnException(:2195-2235) throwRuntimeExceptionfrom it during rollback. None throws at this hand-off.IndexWriter.onTragicEventguards its own logging withcatch (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 sameupdateStallState()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), intoupdateStallState(), which the hand-off already called in itsfinally. - Precedent. #5345 (LUCENE-4277, 2012): a flush-pending buffer left behind in the flush queue (by a full-flush race) made
aborthang inwaitForFlush; fixed as a bug by pruning such buffers in abort. #6053 (LUCENE-4989, 2013): an exception that skippeddoAfterFlush; fixed with afinally.
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).
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;
addDocumentrethrows the Error and the writer is closed. - Closest suites with the fix (assertions on, seed DEADBEEF):
TestIndexWriterExceptions(40 tests, including the InfoStreamOutOfMemoryErrortests),TestIndexWriterExceptions2,TestFlushByRamOrCountsPolicy,TestDocumentsWriterStallControl,TestIndexWriterWithThreads,TestIndexWriterThreadsToSegments,TestIndexWriter,TestInfoStream,TestIndexWriterDelete,TestIndexWriterCommit: 10 classes, 230 tests, 0 failures, 13 skipped. :lucene:core:checkGoogleJavaFormat,ecjLintMainandforbiddenApisMainpass.- 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 nextaddDocumentreturn after an exception) and the no-throw control pass. Its twoisEnabledtests 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.
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
a9779ef3c193with this patch (the Errors close the writer;close(),rollback()and the nextaddDocumentreturn after the exceptions).
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.
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.
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.
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 oneOutOfMemoryErrorat the k-thInfoStream.isEnabledcall, for every k of the 1,052 calls. - 12 runs, all at
DocumentsWriterFlushControl.updateStallState, hung inDocumentsWriter.abort->waitForFlushduring the tragic rollback. The sweep'smessage()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.
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).
| Where | When | How | Found |
|---|---|---|---|
| scripted prior-report search, 3 runs (identifiers, symptoms, mechanism) | 2026-10-28 | each run: GitHub issues, PRs and commits; Elasticsearch, OpenSearch and Solr; the dev@, java-user@, issues@ and commits@ lists; the JIRA archive | no report of this defect |
| GitHub (apache/lucene), manual | 2026-10-28 | 58 queries: checkOutForFlush, updateStallState, waitForFlush, flushingWriters, addFlushingDWPT, "now stalling flushes", hang / OOM / InfoStream phrasings, the last-48-hour legs | related fixed bugs only: #5345 (LUCENE-4277), #6053 (LUCENE-4989), #12654 / #12751, #6066; #7271 (LUCENE-6209) added the stall messages |
| Open PRs | 2026-10-28 | the 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 |
| Web | 2026-10-28 | 10 queries (identifiers, stack frames, symptoms, downstream) | nothing relevant |
| GitHub re-check | 2026-10-31 | checkOutForFlush, updateStallState, "now stalling flushes", flushingWriters, waitForFlush hang, "InfoStream throws", "InfoStream OutOfMemoryError"; every issue and PR created since 2026-10-28 (recorded) | nothing new |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails with assertions on and off (gradle and the standalone runner); passes with either fix |
| 10.6.0 | affected | the 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 | affected | the version-neutral probe hangs in all four fault scenarios (Error and exception, from message() and from isEnabled()) |
| 5.5.5, 6.1.0 | not affected | no 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 pair | 6.1.0 with the hunk hangs; 6.2.0 without it does not |
upstream main 2f424fd428da (2026-10-30) | read | the same hand-off and logging; #16867 changed the stall condition only |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | Sweep: 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 agrees | Contract 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 agrees | Rated 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), hang | Re-ran the independent reproduction's class with new seeds: 6 of 7 fail, the control passes. |
| History follow-up | history corrected | First affected release 6.2.0 (not 7.0.0, and not LUCENE-5006); the shadow pair isolates 614b45d2dff4. |
| Registration check and credibility audit | keep | The 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 validated | The 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 corrections | Re-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 corrections | Checked 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 503 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,416 words
- 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 maina9779ef3c193and passes on 5.5.5 and 6.1.0. Upstream main2f424fd428dahas the same hand-off (read, 2026-10-31).
- 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-790lucene/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-6106lucene/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:2684lucene/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-29lucene/core/src/test/org/apache/lucene/index/TestIndexWriterExceptions.java:1110-1147, (at 645d4fe14a20) :1149-1200, :1980-2052, :2195-2235GitHub 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-790lucene/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)
- TestErrorInStallMessageClosesWriter.java (one test):
repro/LFV-846/src/org/apache/lucene/index/TestErrorInStallMessageClosesWriter.java(77 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed minimal test.
assets/LFV-846/bug.png(503 KB)
