All findings · Lucene finding · LFV-1078 · prepareflush-failure-drops-dwpt-and-frozen-global-packet

A failure inside prepareFlush is not tragic: IndexWriter stays open, and the next commit silently lacks acknowledged documents, deletes and updates

When a commit, flush, forceMerge or NRT reopen fails while Lucene prepares an in-memory segment for flushing, the writer stays open and reports no tragic exception, yet has dropped that segment and the deletes and updates it had just frozen. The next successful commit() or close() silently lacks them.

Summary

Priority
Medium, rank 25In released 9.8.0 to 10.6.0 on the default IndexWriter, a non-tragic exception inside prepareFlush leaves the writer open, and the next successful commit or close silently lacks acknowledged documents, deletes and updates. The trigger is rare, but such a non-tragic prepareFlush failure has been seen in production and an over-long delete term reaches the same path, so it sits high in Medium.
Impact
data corruption
Since
4.0.0 through a throwing InfoStream (the handling dates from LUCENE-2956, 2011); 9.8.0 (2023, exposed by #12392) through the executed overflow triggerintroduced 2023-06-28 · about 3 years
Affected
9.8.0 through 10.6.0 and main; through a throwing InfoStream every release since 4.0.0
Status
documented
Found by
code reading of the delete-buffer copies next to #16867, witnessed by public-API probes and a model-based fault injection

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Flushing an in-memory segment starts with prepareFlush: it first freezes the writer-wide buffered deletes and updates into a packet and clears them, then applies the segment's own copy of them. If that second step throws, doFlush has no flush ticket to carry the packet, and its finally drops the segment. Nothing marks the writer as broken.

How IndexWriter flushes buffered work, in one minute

  1. Each indexing thread fills its own in-memory segment (a DWPT). Deletes and doc-values updates go into one writer-wide queue; each in-memory segment also keeps a slice of that queue (everything since its last document) to apply to its own documents.
  2. To flush a segment, doFlush asks prepareFlush for a flush ticket. The ticket carries, in order, the frozen writer-wide deletes and updates (for the older segments) and, once written, the new segment.
  3. A failure while the segment is written (DocumentsWriterPerThread.flush) is tragic: Lucene aborts the segment and the writer closes itself, so the last commit stays the recovery point.
A flow diagram on a light card: the application feeds a shared delete queue (deletes and updates) and a per-thread writer (new documents); both flow into prepareFlush(), which first takes the shared deletes out as a frozen packet and then copies the writer's own slice of them; the result becomes a flush ticket (frozen packet plus new segment) that reaches the index; a red ring and cross mark the second step, with the note that if it throws no ticket exists yet, so the packet and the documents are dropped with no tragic event while the writer stays open, and two panels below contrast the expected outcome (the writer closes itself and the last commit stays intact) with the actual one (the writer stays open and the next commit lacks the document, the delete and the latest updates).
prepareFlush() takes the shared deletes out before the step that can fail, and the flush ticket that would carry them exists only after it returns, so a failure there silently drops them and the writer's documents. Generated with AI, checked against the code.
Summary card of the minimal repro (one thread, executed on main): a committed document victim with a 1 KB id, a buffered document waiting in an in-memory segment, 2,200,000 doc-values updates of victim and its delete (seqNo 2,200,005); the commit's prepareFlush freezes and clears the shared deletes and updates, then its own copy passes 2 GiB and throws ArithmeticException; no flush ticket exists, so doFlush drops the packet and the segment without a tragic event; the writer stays open and the retried commit (seqNo 2,200,008) lacks the buffered document, keeps victim live and holds a stale value. Expected: the writer closes itself or the commit holds buffered=1 victim=0; actual: buffered=0 victim=1. The fix makes the failure tragic, so the writer closes and the last commit stays intact.
The minimal repro step by step, one thread: what the test calls on the left, the state inside IndexWriter on the right, then expected versus actual and the fix. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Commit one document whose id is a 1 KB term (victim). Add a document buffered; it waits in its own in-memory segment.
  2. Call updateNumericDocValue(victim, "n", i) 2,200,000 times, then deleteDocuments(victim) (sequence number 2,200,005). Every call succeeds; ramBytesUsed() reports 2.6 MB just before the commit (at most 16.8 MB during the updates).
  3. commit() throws ArithmeticException: integer overflow: preparing the in-memory segment copies all 2.2 million updates since buffered into its own buffer, about 2.25 GB, past the 2 GiB a byte pool can address. isOpen() is true, getTragicException() is null.
  4. You expect the writer to close itself, as for any other failure while flushing a segment, or the next commit to hold every operation its sequence number covers.
  5. Instead the next commit() succeeds and returns 2,200,008, yet buffered is missing, victim is still live, and its n is 2,197,624: the document, the delete and the last 2,376 updates are gone.

Who is affected

Applications that keep using the writer, or simply close() it (default commitOnClose), after a commit, flush, forceMerge or NRT reopen failed in this step: isOpen() says true, getTragicException() null. The executed trigger needs about 2 GiB of update terms behind an idle in-memory segment; an over-long delete term and a throwing InfoStream reach the same handling.

Mechanism, terms and numbers705 words

Flushing an in-memory segment (a DWPT) starts in DocumentsWriter.doFlush, which creates its flush ticket with ticketQueue.addTicket(() -> new FlushTicket(dwpt.prepareFlush(), true)) (DocumentsWriter.java:534-536). prepareFlush first freezes the writer-wide buffered deletes and doc-values updates into a packet and clears them (DocumentsWriterPerThread.java:429, DocumentsWriterDeleteQueue.java:269-273), then applies the segment's own slice of the delete stream (:434), which can throw. When it does, there is no ticket: the catch skips markTicketFailed (DocumentsWriter.java:556-563), so the packet is lost, and the finally drops the DWPT with its buffered documents (:576-578). doFlush issues no abort and no tragic event for this DWPT, so isOpen() stays true and getTragicException() null, and the next successful commit(), or close() with the default commitOnClose, persists an index without operations its sequence number covers. Every failure inside the segment write itself is tragic (DocumentsWriterPerThread.java:585-604). The executed trigger, about 2^31 bytes of update terms behind one idle in-memory segment, reaches this step since 9.8.0 (#12392 made the overflow throw there; 9.7.0 closed the writer instead); an InfoStream that throws on the flush's first message reaches the same handling in every release since 4.0.0. Making a failure on this path tragic in doFlush fixes both.

The code

// DocumentsWriter.doFlush, main 5adff78bb168                         DocumentsWriter.java:502
DocumentsWriterFlushQueue.FlushTicket ticket = null;                 // :506
try {
  try {
    ticket = ticketQueue.addTicket(                                   // :534-536
        () -> new DocumentsWriterFlushQueue.FlushTicket(dwpt.prepareFlush(), true));
    ... flushingDWPT.flush(flushNotifications) ...                    // :541, the tragic-safe part
  } catch (Throwable t) {                                             // :556
    if (ticket != null) {                                             // :557  null: prepareFlush threw
      ticketQueue.markTicketFailed(ticket);                           // :561  skipped: the packet is lost
    }
    throw t;                                                          // :563  no abort, no tragic event
  }
} finally {
  flushControl.doAfterFlush(flushingDWPT);                            // :577  the DWPT and its documents go
}

// DocumentsWriterPerThread.prepareFlush                                DocumentsWriterPerThread.java:427
final FrozenBufferedUpdates globalUpdates = deleteQueue.freezeGlobalBuffer(deleteSlice); // :429 packs + clears
if (deleteSlice != null) {
  deleteSlice.apply(pendingUpdates, numDocsInRAM);                    // :434  throws here
  ...
}

Step by step

  1. The thread that added buffered then only updates and deletes, so its in-memory segment's slice spans all 2,200,001 later operations. commit() reaches doFlush through prepareCommitInternal (IndexWriter.java:3809) and flushAllThreads / maybeFlush (DocumentsWriter.java:688, :496); flush(), forceMerge() (IndexWriter.java:2248, :4402) and an NRT reopen (:586) take the same path.
  2. freezeGlobalBuffer applies the global slice, builds a FrozenBufferedUpdates packet and clears the writer-wide buffer (DocumentsWriterDeleteQueue.java:217-237, :262-277, clear at :272). Here the packet holds the delete of victim and the updates since the writer-wide buffer was last applied (it is applied whenever it reaches the 16 MB RAM buffer, FlushByRamOrCountsPolicy.java:52-53).
  3. Applying the segment's slice appends each update's 1 KB term to the segment's private buffer: 2.2 million updates need about 2.25 GB, and Math.addExact in ByteBlockPool.nextBuffer throws ArithmeticException at 2 GiB (ByteBlockPool.java:199).
  4. addTicket gives its ticket count back and rethrows (DocumentsWriterFlushQueue.java:48-50). ticket is still null, so nothing holds the packet, and the finally drops the DWPT with buffered. doFlush calls neither flushFailed, abort() nor onTragicEvent for this DWPT (a failed full flush's cleanup, finishFullFlush(false) -> abortFullFlushes(), aborts only other DWPTs still queued or blocked for that flush, DocumentsWriter.java:727-745, DocumentsWriterFlushControl.java:685-723; the one-thread repro has none); getPendingNumDocs() stays one too high (2 for an index of 1 document).
  5. Contrast: DocumentsWriterPerThread.flush treats any Throwable as aborting (:585-587), and maybeAbort aborts and fires the tragic event ("whatever we do here we have to fire this tragic event up", :594-604). Two steps that can throw an ordinary exception run outside that guard: prepareFlush, and flush's first InfoStream message ("flush postings as segment", :479-483, before the try at :485). A non-tragic exception there is dropped the same way; the ticket already exists then, so only the segment's documents are lost.

The numbers (main, assertions off)

Sequence numbers: victim 1, first commit 3, buffered 4, last update 2,200,004, delete 2,200,005; ramBytesUsed() 2,586,576 bytes before the commit. The commit throws; isOpen() true, getTragicException() null, getPendingNumDocs() 2. The retried commit returns 2,200,008 and holds 1 document: buffered 0 hits, victim 1 hit with n = 2,197,624. With the fix the commit throws the same exception and the writer closes itself (getTragicException() is that exception, getPendingNumDocs() 1).

Exposure and likelihood479 words
  • Who is hit. Applications that keep using an IndexWriter, or close it with the default commitOnClose (the try-with-resources idiom), after commit(), flush(), forceMerge(), an NRT reopen or flushNextBuffer() threw from this step. The writer gives no signal to stop: isOpen() is true and getTragicException() is null, the two signals LUCENE-6217 (#7279) added for that decision (#9738's description says Solr's leader election, for one, decides from getTragicException()). Earlier commits stay intact, and rollback() right after the failure restores the last commit (assertions off).
  • What is lost. The in-memory segment's documents and the deletes and doc-values updates in the frozen packet: in the repro the buffered document, the delete of a committed document (it stays searchable) and the last 2,376 of 2,200,000 updates (a stale value). The index stays structurally valid; no hang.
  • Executed entry points. commit() (the repro); forceMerge(1), after which the next commit lacks the document and the delete; and close() after a failed commit(), which returns normally and commits the same incomplete state. By code reading, flush(), NRT reopen, IndexWriter.flushNextBuffer() (IndexWriter.java:3758-3771, which treats only an Error as tragic, -> DocumentsWriter.flushOneDWPT, DocumentsWriter.java:246-258) and an indexing call whose document makes its DWPT due for flushing (DocumentsWriter.postUpdate, :400-410) reach the same doFlush path.
  • Default vs opt-in. Default IndexWriterConfig, codec and directory, one thread, assertions on or off.
  • Triggers of the same handling. (1) Executed: about 2^31 bytes of doc-values-update (or delete) terms behind one idle in-memory segment, for example 2.2 million updates of a 1 KB id; ramBytesUsed() never comes near the 2 GiB the flush will need (it peaks at about 16.8 MB during the updates and reads 2.6 MB just before the commit), so nothing warns (its root, the unbounded per-segment copy, is LFV-1077). Released 9.8.0 to 10.6.0 and main. (2) An over-long delete term makes prepareFlush's freeze throw (LFV-1046, 9.9.0 and later). (3) An InfoStream whose message() throws on "flush postings as segment": the retried commit has 0 documents (executed on main, 10.6.0, 10.5.2 and 9.12.3, and, in a version-neutral main() form with assertions off, on eight releases from 4.0.0 to 9.0.0: 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4 and 9.0.0). (4) In production, elastic/elasticsearch#160855 (Lucene 10.5.1) logs a non-tragic IllegalStateException out of freezeGlobalBuffer <- prepareFlush <- doFlush <- IndexWriter.flushNextBuffer(), during an indexing-buffer flush inside a bulk request (Elasticsearch logs it with the message "Failed to perform engine refresh"), as a warning.
  • Likelihood: rare. It needs a non-tragic exception out of this step and then an application that continues or just closes. The 2 GiB trigger alone would be contrived; the other triggers and the production log make it rare.
  • Side observation, not part of this finding. With assertions on, a later rollback() or close() fails the internal pendingNumDocs ... != ... totalMaxDoc assertion (#10661 is the generic report).

Minimal repro

One JUnit test, one thread, default IndexWriterConfig, ByteBuffersDirectory, public API only. It is @Monster (a 3 GB heap, 5-8 s) because the trigger needs 2.2 million updates. It accepts a closed writer or a complete next commit; on main it gets buffered=0 victim=1, assertions on and off, and it passes with the fix.

TestFailedPrepareFlushLosesOps.java (one test, lucene/core)63 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 java.util.Collections;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.TermQuery;
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.LuceneTestCase.Monster;
import org.apache.lucene.util.BytesRef;

@Monster("buffers about 2.2 GB of doc-values updates; needs -Xmx3g")
public class TestFailedPrepareFlushLosesOps extends LuceneTestCase {

  public void testNextCommitKeepsAcknowledgedOps() throws Exception {
    Term victim = new Term("id", new BytesRef(new byte[1024]));
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriter w = new IndexWriter(dir, new IndexWriterConfig());
      Document d = new Document();
      d.add(new StringField("id", victim.bytes(), Field.Store.NO));
      d.add(new NumericDocValuesField("n", 0));
      w.addDocument(d);
      w.commit();
      Term buffered = new Term("id", "buffered");
      w.addDocument(Collections.singletonList(new StringField("id", "buffered", Field.Store.NO)));
      for (int i = 1; i <= 2_200_000; i++) {
        w.updateNumericDocValue(victim, "n", i);
      }
      w.deleteDocuments(victim);
      expectThrows(ArithmeticException.class, w::commit);
      if (w.isOpen()) {
        w.commit();
        try (DirectoryReader r = DirectoryReader.open(dir)) {
          // expected buffered=1 victim=0 (or a closed writer); actual: open writer, both ops lost
          IndexSearcher s = new IndexSearcher(r);
          int b = s.count(new TermQuery(buffered)), v = s.count(new TermQuery(victim));
          assertEquals("buffered=1 victim=0", "buffered=" + b + " victim=" + v);
        }
        w.rollback();
      }
    }
  }
}
Command
cd $LUCENE
cp $REPRO/TestFailedPrepareFlushLosesOps.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestFailedPrepareFlushLosesOps -Ptests.monster=true -Ptests.heapsize=3g -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
After the failed commit() the writer has closed itself, or the next commit has buffered=1 victim=0.
Observed
ComparisonFailure: expected:<buffered=[1 victim=0]> but was:<buffered=[0 victim=1]>, with assertions on and off.

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

More files, environment, full output216 words

new IndexWriterConfig() (the test framework picks a random default codec; the failing step, prepareFlush, runs before any codec code, and the test failed with every seed tried), a ByteBuffersDirectory, one thread, no package-private access (it sits in org.apache.lucene.index like the upstream tests). Run it with -Ptests.monster=true -Ptests.heapsize=3g. It passes :lucene:core:check -x test (google-java-format, ecj lint, forbidden APIs).

The test expects commit() to throw ArithmeticException and then accepts either a closed writer (the fix) or an open writer whose next commit has buffered once and victim gone. On the released 9.7.0 it stops at expectThrows, because there the commit throws ArrayIndexOutOfBoundsException (and the writer closes itself).

Regression test: this same test. It passes with the recommended fix.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT); this test also fails on the released 10.6.0 and 9.8.0, assertions on and off
jdkOpenJDK 25.0.3
osLinux, aarch64

Observed output

TestFailedPrepareFlushLosesOps > testNextCommitKeepsAcknowledgedOps FAILED
    org.junit.ComparisonFailure: expected:<buffered=[1 victim=0]> but was:<buffered=[0 victim=1]>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:DEE1DBA70DD1CA8D]:0)
        at app//org.junit.Assert.assertEquals(Assert.java:117)
        at app//org.junit.Assert.assertEquals(Assert.java:146)
        at app//org.apache.lucene.index.TestFailedPrepareFlushLosesOps.testNextCommitKeepsAcknowledgedOps(TestFailedPrepareFlushLosesOps.java:57)

# the same steps as a plain main() (numbers only, assertions off): where the commit throws
commit 2 threw java.lang.ArithmeticException: integer overflow
    at org.apache.lucene.util.ByteBlockPool.nextBuffer(ByteBlockPool.java:199)
    ...
    at org.apache.lucene.index.DocumentsWriterDeleteQueue$DeleteSlice.apply(DocumentsWriterDeleteQueue.java:364)
    at org.apache.lucene.index.DocumentsWriterPerThread.prepareFlush(DocumentsWriterPerThread.java:434)
    at org.apache.lucene.index.DocumentsWriter.lambda$doFlush$0(DocumentsWriter.java:536)
    at org.apache.lucene.index.DocumentsWriterFlushQueue.addTicket(DocumentsWriterFlushQueue.java:42)
    at org.apache.lucene.index.DocumentsWriter.doFlush(DocumentsWriter.java:535)
after: isOpen=true tragic=null pendingNumDocs=2
commit 3 returned 2200008 numDocs=1 buffered=0 victim=1
commit 3 doc 0 n=2197624

Why it looks unintended

  • commit() promises "All sequence numbers <= this value will be reflected in the commit"; the retried commit returns 2,200,008 but lacks the delete (2,200,005).
  • forceMerge promises "no documents will be lost" on an Exception; forceMerge(1) fails the same way and the next commit lacks the operations.
  • Every other failure while flushing a segment is tragic (LUCENE-5987: no "silent data loss").
The full argument261 words
  • commit(). "it returns the sequence number such that all indexing operations prior to this sequence will be included in the commit point" and "All sequence numbers <= this value will be reflected in the commit" (IndexWriter.java:4202-4204, :4207-4209).
  • forceMerge. "If an Exception is hit, for example due to disk full, the index will not be corrupted and no documents will be lost." (IndexWriter.java:2213-2214). It starts with flush(true, true) (:2248); executed, it throws the same ArithmeticException, the writer stays open and the next commit lacks the operations. (That javadoc paragraph is about merging, :2213-2217; here the exception comes from the flush forceMerge starts with, so this is the weaker of the two contracts.)
  • The design rule. LUCENE-5987 (#7049): "IndexWriter will now forcefully close itself on aborting exception (an exception that would otherwise cause silent data loss)." (lucene/CHANGES.txt:11614-11616). The segment write follows it for every Throwable (DocumentsWriterPerThread.java:585-604); IndexWriter.flushFailed carries "TODO: this really should be a tragic" (IndexWriter.java:6242); getTragicException() names "disk full while flushing a new segment" as its example (:6108-6109).
  • A live upstream test retries after non-tragic commit failures and asserts nothing is lost: TestIndexWriterExceptions.testExceptionDuringSync (LUCENE-1044, TestIndexWriterExceptions.java:943-970, 23 documents). (TestIndexWriterDelete.testErrorAfterApplyDeletes states the same expectation but has been @Ignored since 2017, TestIndexWriterDelete.java:802.)
  • History. Before #12392 (9.8.0) the same workload overflowed later, in FrozenBufferedUpdates applied through IndexWriter.forceApply, which closes the writer: on 9.7.0 nothing is lost. #12392 is correct; it moved the throw into the one flush step that is not tragic.
The devil's-advocate check216 words

The argument: a maintainer wrote on open #9738 (2019) that exceptions from doFlush(), prepareCommitInternal() and others "might cause documents go missing etc. but we treated them not as fatal or tragic events since a user could always call rollback()", and that "if somebody continues after a failed commit / prepareCommit / reopen they will end up with inconsistency and / or dataloss". TwoPhaseCommit says "If the 2-phase commit fails, rollback() is called" (TwoPhaseCommit.java:31), and the class javadoc promises a self-close only "if you hit an Error, or disaster strikes during a checkpoint" (IndexWriter.java:162-165).

Why it fails: that stance is in no javadoc, MIGRATE or CHANGES entry, and the same maintainer called the current behaviour inconsistent ("I am all for making it even more strict especially since it's inconsistent with what we do if addDocument fails with an aborting exception"). forceMerge's javadoc promises the opposite. close() with the default commitOnClose persists the loss with no retry at all, and under the default KeepOnlyLastCommitDeletionPolicy that commit replaces the last good one, so rollback() can no longer help. isOpen() and getTragicException() tell the caller the writer is fine. An executed review by another model family (OpenAI GPT-5.6, intended-behaviour lens) rated the argument weak, and a devil's-advocate verifier rejected it on the same grounds.

Suggested fix

Recommended A: doFlush makes a failure the DWPT did not already treat as aborting tragic

In DocumentsWriter.doFlush's catch, when the in-memory segment was not already aborted, give back its document count, abort it and fire onTragicEvent, as DocumentsWriterPerThread.flush does. The writer then closes itself and the last commit stays intact. Validated: the repro passes on and off; the org.apache.lucene.index tests (1,399) and :lucene:core:check pass.

All options and trade-offs4 options ยท 663 words

Validated with gradle in a clean scratch worktree of 5adff78bb168 (OpenJDK 25.0.3, seed DEADBEEF, 2026-11-03): the repro fails stock with assertions on and off and passes with fix A, on and off. A check test for the InfoStream step (an InfoStream that throws on "flush postings as segment"; not part of the repro) fails stock and passes with fix A, on and off. With fix A and both tests in place, :lucene:core:test --tests 'org.apache.lucene.index.*' passes (216 test classes, 1,399 tests, 0 failures, 57 skipped: nightly, monster or otherwise gated; the monster repro ran separately) and so does :lucene:core:check -x test. The scratch tree was clean after reverting.

Why one catch covers both unguarded steps: a failure inside DocumentsWriterPerThread.flush's guard has already aborted the DWPT (isAborted() is true), so the new branch runs only for prepareFlush and flush's first InfoStream message; the rest of the block only enqueues events (IndexWriter.java:437-444). The tragic event only records the tragedy; the entry point's maybeCloseOnTragicEvent() in its finally then closes the writer (for commit(), IndexWriter.java:3883 in prepareCommitInternal). This is reasoning from the source plus the runs above; no stress or performance run was made.

A: doFlush makes a failure the DWPT did not already treat as aborting tragic (validated by the checks) (recommended)

In DocumentsWriter.doFlush's catch (Throwable t), after the markTicketFailed block: if flushingDWPT.isAborted() == false, subtract the DWPT's documents from the in-RAM count when no ticket was made, call flushingDWPT.abort() (which also gives back the writer's pending document count, DocumentsWriterPerThread.java:98-100) and, in a finally, flushNotifications.onTragicEvent(t, "flush"); then rethrow. 15 lines added, DocumentsWriter.java only. It mirrors DocumentsWriterPerThread.maybeAbort (:594-604).

} catch (Throwable t) {
  if (ticket != null) {
    ticketQueue.markTicketFailed(ticket);
  }
  if (flushingDWPT.isAborted() == false) {
    // prepareFlush, or DWPT#flush's first log line, failed outside DWPT#flush's aborting guard
    try {
      if (ticket == null) { // else the finally above already subtracted them
        subtractFlushedNumDocs(flushingDWPT.getNumDocsInRAM());
      }
      flushingDWPT.abort();
    } finally {
      flushNotifications.onTragicEvent(t, "flush");
    }
  }
  throw t;
}

Trade-offs

An application that today retries commit() after such a failure now gets AlreadyClosedException and must open a new writer: the stance LUCENE-5987 took for every other flush failure. The frozen packet is still dropped, but the writer closes, so nothing incomplete is committed and the last commit stays the recovery point. Not stress- or performance-tested.

What the checks validated

  • The repro passes with assertions on and off (gradle); the same steps as a plain main() show the writer closed with the ArithmeticException as its tragic exception.
  • The InfoStream check test passes with assertions on and off.
  • org.apache.lucene.index test package with both tests: 216 classes, 1,399 tests, 0 failures, 57 skipped.
  • :lucene:core:check -x test (google-java-format, ecj lint, forbidden APIs) passes.

B: the same, but only around the addTicket / prepareFlush call (validated by the checks)

Wrap only ticket = ticketQueue.addTicket(... prepareFlush() ...) in a catch that gives back the count, aborts the DWPT, fires onTragicEvent(t, "prepareFlush") and rethrows.

Trade-offs

Narrower, but it leaves the InfoStream step losing the segment's documents (executed: that check test still fails with it).

What the checks validated

On main 645d4fe14a20, whose DocumentsWriter.java is identical to 5adff78bb168's: the repro passes on and off; 6 upstream classes pass (104 tests); a model-based fault injection shows 0 violations over 1,176 injected faults.

C (not sufficient alone, not run): freeze the writer-wide buffer only after the segment's slice is applied

Swap the two steps of prepareFlush.

Trade-offs

Keeps the deletes and updates in the writer-wide buffer, but doFlush still drops the segment's documents silently; it still needs a tragic event or a documented contract.

D (not sufficient alone, not run): carry the frozen packet into a failed ticket, plus abort

Create the ticket before the failing step so markTicketFailed publishes the packet, and abort the DWPT.

Trade-offs

Keeps the deletes and updates of committed documents, but still loses the buffered documents, so it still needs a tragic event or a documented contract.

Downstream note

Products that rely on deletes or updates taking effect (content removal, access changes) and keep using the writer after a failed commit or NRT reopen can serve a deleted document or a stale value after the next commit.

How it was found

Code reading next to the recent upstream fix #16867, which caps the writer-wide delete buffer, looked for other unbounded copies of the delete stream and found an idle in-memory segment's own copy. Public-API probes showed a commit failing there and the next commit missing operations; it was reduced to one test and confirmed by model-based fault injection.

The method in full214 words

Method: non-formal (code reading witnessed by executed public-API probes), on main 645d4fe14a20, 2026-11-02; confirmed with a model-based test.

  • #16867 (merged 2026-10-30) caps the bytes of buffered deletes and updates in the writer-wide delete queue at 1,945 MB, because the byte pools use 32-bit offsets. Reading the code around it for other copies of the same stream found the per-segment slice applied in prepareFlush, which has no cap, and the non-tragic handling when that step throws.
  • Public-API probes (default configuration) then showed commit() throwing ArithmeticException there, the writer staying open, and the next commit missing the buffered document, a delete of a committed document and updates. The case was reduced to the one JUnit test of this page.
  • A model-based test enumerated 1,554 operation sequences of up to 4 steps (adds, deletes of committed and buffered documents, doc-values updates) with a fault injected after 0 to 4 nodes of the segment's slice inside prepareFlush (7,770 scenarios, a test-only copy of the class): every one of the 1,176 faults that fired broke the oracle (a tragic close with the last commit intact, or a next commit equal to the model), and none did with the fix.
  • The second unguarded step (DocumentsWriterPerThread.flush's first InfoStream message) was found while testing InfoStream callbacks that throw.

Prior reports, history and checks

Prior reports

No report of this defect found (GitHub issues and PRs incl. the JIRA archive, the mailing lists, downstream trackers, the web; re-checked 2026-11-03 in two independent passes and again before publishing). Related, different defects: #9738 (getTragicException misses corrupting exceptions; a comment there names doFlush among non-tragic paths) and elastic/elasticsearch#160855 (a logging request whose log shows a non-tragic prepareFlush failure).

Searches4 searches

No Lucene issue, PR, JIRA entry or list thread, and nothing outside Lucene, describes this defect. Related prior items, each about a different defect:

  • #9738 (LUCENE-8692, open, 2019): getTragicException() does not reflect all corrupting exceptions (notably NoSuchFileException), found by a test that corrupts index files under a running writer, so Solr did not give up shard leadership after a failed commit. In its discussion a maintainer (s1monw) lists doFlush() among methods whose non-tragic exceptions "might cause documents go missing", with rollback() as the remedy. A general design stance: no mechanism, no prepareFlush, no trigger, no fix, and nothing about a later commit that succeeds while its sequence number covers lost operations.
  • elastic/elasticsearch#160855 (open, 2026-10, Lucene 10.5.1): asks for better logging when downsampling fails. Its log shows bulk items failing with ArithmeticException: integer overflow, then a non-tragic IllegalStateException("buffer was finished already") out of prepareFlush during an indexing-buffer flush (IndexWriter.flushNextBuffer(), logged by Elasticsearch as "Failed to perform engine refresh"), as a warning. It does not look at what happened to the buffered documents.
  • Also related: #14261 (open: a nightly OutOfMemoryError inside DeleteSlice.apply <- prepareFlush; an Error is tragic), #10661 (the pendingNumDocs != totalMaxDoc assertion after a tragic flush), #7049 (LUCENE-5987, the self-close rule), #4106 (LUCENE-3032, an NPE in prepareFlush fixed by a null guard), #12392, #12647, #14913, #16867 (the code history).
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive); Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA; web2026-11-02a scripted prior-art run of 60 queries (142 distinct hits) plus manual GitHub, list and JIRA searchesonly the related items above
Open PRs2026-11-02the changed lines of all 410 open apache/lucene PRs25 touch the files involved; none changes prepareFlush, doFlush's failure path, freezeGlobalBuffer or onTragicEvent
Re-check (two independent passes, then this doc)2026-11-0322 apache/lucene searches (prepareFlush, freezeGlobalBuffer, markTicketFailed, tragic flush, overflow, lost after failed commit, InfoStream wordings), prepareFlush on Elasticsearch, OpenSearch and Solr, and every apache/lucene issue and PR updated since 2026-11-02 (52)nothing new
Last check before publishing2026-11-035 apache/lucene issue and PR searches (prepareFlush, freezeGlobalBuffer, markTicketFailed, tragic flush commit lost, doFlush tragic) and every apache/lucene item updated since 2026-11-03nothing new (only #14261 and #4106, listed above)

History

Through the executed overflow trigger users can hit it since 9.8.0: #12392 (2023, correct in itself) made this overflow throw inside prepareFlush; on 9.7.0 this workload fails later and closes the writer. The handling dates from LUCENE-2956 (2011); a throwing InfoStream reaches it in every release since 4.0.0. Affected through 10.6.0 and main; not fixed upstream as of 2026-11-03.

Versions tested7 rows

Exposing change: #12392 "Catch offset overflows in byte pool (#9660)" (b88d3e19889 on main, 9.x backport 445b30d421c, Stefan Vodita, 2023-06-28; first releases 9.8.0 and 10.0.0). Its Math.addExact makes the 2 GiB overflow throw at once, which for this workload is inside prepareFlush; between 9.7.0 and 9.8.0 the 11 files on the failure path differ only in that line. The handling itself dates from LUCENE-2956 "Support updateDocument() with DWPTs" (e95097a7c8d, Simon Willnauer, 2011-04-13; first release 4.0.0-alpha), restructured without behaviour change by LUCENE-3692 (2012), #12647 (2023) and #14913 (2025); an injected fault right after the freeze loses the operations on 8.8.1 and 9.7.0 too. Not fixed upstream: upstream main f72004515ec (2026-11-03); the last commits on DocumentsWriter.java, DocumentsWriterPerThread.java, DocumentsWriterDeleteQueue.java, DocumentsWriterFlushQueue.java and ByteBlockPool.java predate 5adff78bb168, and #16867 (in 5adff78bb168) does not touch this path.

Versions and branches executed, with results
Version or commitVerdictResult
4.0.0 to 9.0.0 InfoStream triggera throwing InfoStream on the flush's first message loses the segment's documents (version-neutral main() form, assertions off, run on 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4 and 9.0.0; the releases in between by reading)
8.8.1, 9.7.0 injected faulta fault injected right after the freeze loses the document and the delete; no public-API trigger of this step known
9.7.0 fails loudlythis workload: the commit throws ArrayIndexOutOfBoundsException -65536 and the writer closes itself; nothing lost
9.8.0 first: 9.8.0this test fails, assertions on and off
9.9.0, 9.12.3, 10.0.0, 10.5.2 affectedan earlier form of the test (separate update target) or an independent test fails
10.6.0 affectedthis test fails, assertions on and off
main 5adff78bb168 affectedthis test fails through gradle, on and off; passes with fix A

Independent checks

An independent reproducer, two executed reviews by another model family (OpenAI GPT-5.6), a devil's-advocate verifier and an adjudication all rated it real with their own tests; this doc's gradle runs reproduced it on main 5adff78bb168 and validated the fix.

Checks and verdicts246 words

The cross-model reviews ran on main ea57360912ed and the verifiers on 645d4fe14a20. Between those mains and 5adff78bb168 three files changed: DocumentsWriterFlushControl.java and FlushByRamOrCountsPolicy.java (#16867, the writer-wide delete-buffer cap) and IndexWriter.java (#16963, one InfoStream line); none touches prepareFlush, doFlush's catch or the null-ticket path.

Each independent check and its verdict
CheckVerdictWhat it found
finder foundOne-test JUnit fails on main (on, off); the model-based fault injection: every fired fault breaks the oracle, 0 with the fix.
independent reproducer realOwn test (2 KB id, 1.1 million updates) fails on main (on, off), 10.6.0 (on, off), 10.5.2, 9.12.3 and 9.8.0; passes on 9.7.0 (the writer closes) and with the fix.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own test fails on and off; its 1,000-update control passes; the fix passes by closing the writer.
cross-model review, intended lens (OpenAI GPT-5.6, executed) real (high)Rated the intended-behaviour argument weak: commit's sequence-number promise is unqualified.
devil's-advocate verifier real (medium-high)Executed forceMerge(1) and close() after a failed commit (both lose the operations); found that a cited upstream test is @Ignore'd (the page says so).
adjudication confirmed (high), MediumRe-ran the reproducer's test with a new seed, assertions off: fails, the writer open with no tragic exception.
this doc (gradle) reproduced, fix validatedThe test fails on main through gradle, on and off; with fix A it passes, and the index test package and :lucene:core:check -x test pass.

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

Finding record

LFV-1078, snapshot of 2026-11-06

Registry title
An exception inside DocumentsWriterPerThread.prepareFlush is not tragic: IndexWriter stays open but drops the in-memory segment and the deletes and doc-values updates it had just frozen, so the next successful commit lacks acknowledged operations its sequence number covers
Area
index / DocumentsWriter flush failure handling (DocumentsWriter.doFlush, DocumentsWriterPerThread.prepareFlush, DocumentsWriterDeleteQueue.freezeGlobalBuffer)
Priority
Medium, rank 25: In released 9.8.0 to 10.6.0 on the default IndexWriter, a non-tragic exception inside prepareFlush leaves the writer open, and the next successful commit or close silently lacks acknowledged documents, deletes and updates. The trigger is rare, but such a non-tragic prepareFlush failure has been seen in production and an over-long delete term reaches the same path, so it sits high in Medium.
Impact class
data corruption
Impact note
Hits applications on the default IndexWriterConfig, codec and directory that keep using an IndexWriter, or close it with the default commitOnClose, after a commit, forceMerge, flush or NRT reopen failed with a non-tragic exception inside DocumentsWriterPerThread.prepareFlush: the writer reports itself healthy (isOpen() true, getTragicException() null) and the next successful commit() or close() silently lacks the acknowledged buffered documents, deletes and doc-values updates that its sequence number covers [exec: commit, forceMerge(1), close(); read: flush(), NRT reopen], while earlier commits stay intact and rollback() right after the failure restores the last commit; the executed trigger is extreme (about 2^31 bytes of doc-values-update terms buffered while one in-memory segment sits idle, through the unbounded per-segment copy that LFV-1077 describes), but an over-long delete term (LFV-1046) reaches the same handling and a non-tragic prepareFlush failure was hit in production on 10.5.1 (elastic/elasticsearch#160855); released 9.8.0 to 10.6.0 for this trigger, while 9.7.0 closes the writer instead. A second public-API trigger of the same handling: an InfoStream that throws a RuntimeException on DocumentsWriterPerThread.flush's first message loses the in-memory segment's documents the same way (writer open, the next commit lacks them) in every release since 4.0.0 [exec on 11 releases from 4.0.0 to 10.6.0 and on main; read for the others].
Summary
You buffer a document, a delete and doc-values updates in an IndexWriter, and commit() fails with an ordinary exception while the in-memory segment is being prepared for flushing (executed: ArithmeticException 'integer overflow' after about 2.2 million updateNumericDocValue calls on a 1 KB id); the writer stays open and reports no tragic exception. You expect either the writer to close itself, as it does for every other failure while flushing a segment, or the next successful commit (or close()) to contain every operation its returned sequence number covers. Instead the failed flush has already taken the buffered deletes and updates out of the shared delete queue and then drops them together with the in-memory segment, so the next commit silently lacks the buffered document, keeps the deleted document live and holds a stale doc-values value.
Introduced
b88d3e19889f (apache/lucene#12392 'Catch offset overflows in byte pool (#9660)'; 9.x backport 445b30d421c7) is the exposing change and correct in itself: ByteBlockPool.nextBuffer's Math.addExact makes the 2 GiB overflow of this workload throw inside prepareFlush (non-tragic, silent loss) instead of failing later in FrozenBufferedUpdates.applyDocValuesUpdates via IndexWriter.forceApply (tragic close, nothing lost) [read: the only changed line on that failure path between 9.7.0 and 9.8.0; exec at release level: 9.7.0 passes, 9.8.0 fails; no commit-level bisect]. The handling defect itself (prepareFlush freezes the global buffer before it applies the per-thread slice, doFlush handles a failure only for a non-null ticket, and the finally drops the DWPT) dates from e95097a7c8df (LUCENE-2956 'Support updateDocument() with DWPTs', realtime_search branch, 2011-04-13; trunk with LUCENE-3023), restructured without semantic change by LUCENE-3692 (ed4894dd8fb, 2012-01-16), #12647 (268dd54a869, 2023-10-12) and #14913 (27d1272984f, 2025-07-14) [read: git history in the full-history clone; exec: an injected fault after freezeGlobalBuffer loses the buffered document and the delete on 8.8.1 and 9.7.0 too (shadow class, history check only)] (2023-06-28 (author date of b88d3e19889f and 445b30d421c7, the exposing change); the handling defect 2011-04-13 (e95097a7c8df, LUCENE-2956))
First affected version
9.8.0 [exec: the JUnit fails with -ea and -da; 9.7.0 passes because the same workload closes the writer tragically]; for the prepareFlush step the handling defect is latent from 4.0.0-alpha (3.6.2 does not contain it) through 9.7.0, with no public-API trigger known there [read; exec only with an injected fault on 8.8.1 and 9.7.0]; for a second unguarded step of the same handling it is not latent: an InfoStream that throws on DocumentsWriterPerThread.flush's first message ('flush postings as segment') loses the in-memory segment's documents from 4.0.0 on [exec: the InfoStream check in a version-neutral main() form fails with -da on 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4 and 9.0.0, and its JUnit form fails on 9.12.3, 10.5.2, 10.6.0 and main with -ea and -da]
Affected versions
[exec] this page's JUnit (on 9.9.0 to 10.5.2 an earlier form of it, with a separate update target) fails on 9.8.0 (-ea and -da), 9.9.0, 9.12.3, 10.0.0 and 10.5.2 (-da), 10.6.0 (released 2026-10-29, 4ae946e5a9b; -ea and -da) and main 645d4fe14a20 (-ea and -da); the independent reproducer's own JUnit fails on main (-ea, -da), 10.6.0 (-ea, -da), 10.5.2, 9.12.3 and 9.8.0; 9.7.0 passes (-ea and -da: the first commit throws ArrayIndexOutOfBoundsException -65536 and the writer closes itself, nothing is lost). [read] every release from 9.8.0 to 10.6.0 by tag scan (Math.addExact present, same null-ticket handling, no tragic event for prepareFlush). Not new in 10.6. Not fixed upstream: GitHub compare 645d4fe14a20 to upstream main e1faeba08bd (2026-11-02, 15 commits) touches no DocumentsWriter* file except DocumentsWriterFlushControl.java (#16867, the shared delete-buffer cap, which does not touch this path; a re-run with f815e679bf6's DocumentsWriterFlushControl.java as a shadow class gives the same result) and neither ByteBlockPool nor IndexWriter; second trigger (an InfoStream that throws on 'flush postings as segment', 2026-11-02): one JUnit fails on main e1faeba08bd, 10.6.0, 10.5.2 and 9.12.3 with -ea and -da, and a main() version of it fails with -da on 9.0.0, 8.11.4, 7.7.3, 6.6.6, 5.5.5, 5.0.0, 4.10.4 and 4.0.0 [exec]; the log line has sat before the flush's guarded block since the per-thread flush was introduced (LUCENE-2324, realtime branch, 2010) [read]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
no report of this mechanism found (2026-11-02, on top of the finder's earlier searches): a scripted prior-report search with 60 queries (GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, the web; 142 distinct hits), manual GitHub, list and JIRA searches, and the changed lines of all 410 open apache/lucene PRs (25 touch the involved files; none changes prepareFlush, doFlush's ticket failure path, freezeGlobalBuffer or onTragicEvent). Related: open #9738 (LUCENE-8692, 2019), where a maintainer (s1monw) lists doFlush() and prepareCommitInternal() among the methods whose non-tragic exceptions 'might cause documents go missing', with rollback() as the remedy, naming no mechanism, trigger or fix. Outside occurrence of the precondition: elastic/elasticsearch#160855 (open, 2026-10, Lucene 10.5.1: a non-tragic IllegalStateException 'buffer was finished already' from freezeGlobalBuffer inside prepareFlush during a refresh, logged as a warning; it asks only for better logging and does not diagnose the fate of the buffered documents). Related, different: #14261 (nightly OutOfMemoryError inside DeleteSlice.apply <- prepareFlush, then the pendingNumDocs assertion; an Error is tragic), #10661 (pendingNumDocs assertion after a tragic flush), #7049 (LUCENE-5987), #4106 (LUCENE-3032), #2013 (LUCENE-938), #12392, #12647, #14913, #16867, elastic/elasticsearch#160939. Refresh (2026-11-02): none of the 17 apache/lucene issues and PRs updated since 2026-10-31 is related, no issue created since then mentions prepareFlush, #9738 is still open (last updated 2019-03-12), and upstream main e1faeba08bd changes no file on this path after 645d4fe. Not fixed upstream. Refresh for the second site (2026-11-02): GitHub issues, PRs and commits, downstream trackers, the mailing lists and the JIRA archive, the items of the last 48 hours and the changed files of all 410 open apache/lucene PRs: nothing about DWPT.flush's first log line; 6 open PRs touch DocumentsWriter(PerThread).java and none changes this line, DWPT.flush's try/catch, doFlush's failure path, flushFailed or onTragicEvent.
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT), 2026-11-03: the one-test repro through gradle in a clean scratch worktree, stock (fails, assertions on and off) and with the recommended fix (passes, on and off); with the fix the whole org.apache.lucene.index test package (216 classes, 1,399 tests, 0 failures) and :lucene:core:check -x test pass, and a check test for the InfoStream step passes too. The same test on the released 10.6.0 and 9.8.0 jars fails, on and off; on 9.7.0 the commit throws a different exception and the writer closes itself.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:427-438 (prepareFlush: freezeGlobalBuffer at :429, then deleteSlice.apply at :434), :584-604 (DWPT.flush: any Throwable -> onAbortingException, maybeAbort -> abort() + onTragicEvent)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:533-536 (ticket = ticketQueue.addTicket(() -> new FlushTicket(dwpt.prepareFlush(), true))), :556-563 (catch: markTicketFailed only if ticket != null), :576-578 (finally: flushControl.doAfterFlush drops the DWPT)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:217-237 and :262-276 (freezeGlobalBuffer -> freezeGlobalBufferInternal builds the FrozenBufferedUpdates packet and clears globalBufferedUpdates at :272)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-165 (self-close on an Error or a checkpoint disaster), :2213-2214 (forceMerge: 'no documents will be lost'), :4203-4211 (commit: 'All sequence numbers <= this value will be reflected in the commit'), :6242-6243 (flushFailed: 'TODO: this really should be a tragic')
  • lucene/core/src/java/org/apache/lucene/util/ByteBlockPool.java:194-199 (nextBuffer: Math.addExact at :199, after the new buffer is installed)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterExceptions.java:943-970 (testExceptionDuringSync: commit retried after non-tragic failures, all 23 documents present) and TestIndexWriterDelete.java:797-800 (@Ignore on testErrorAfterApplyDeletes)
  • lucene/CHANGES.txt:11577-11579 (LUCENE-5987: IndexWriter closes itself on an exception that would otherwise cause silent data loss)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:502-581 (at 5adff78bb168) (doFlush: addTicket around prepareFlush at :534-536, catch at :556-563, finally doAfterFlush at :576-578), :496 (maybeFlush), :688 (flushAllThreads)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:727-745 (at 5adff78bb168) (finishFullFlush: abortFullFlushes on failure); DocumentsWriterFlushControl.java:685-723 (abortFullFlushes, abortPendingFlushes: only the DWPTs still queued or blocked)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:427-439 (at 5adff78bb168) (prepareFlush: freezeGlobalBuffer at :429, deleteSlice.apply at :434), :479-485 (first log line before the try), :585-604 (catch, maybeAbort, 'whatever we do here we have to fire this tragic event up')
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:217-237, (at 5adff78bb168) :262-277 (freezeGlobalBuffer, freezeGlobalBufferInternal: packet built and buffer cleared at :269-273)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushQueue.java:38-52 (at 5adff78bb168) (addTicket: decTickets on a throw)
  • lucene/core/src/java/org/apache/lucene/util/ByteBlockPool.java:187-200 (at 5adff78bb168) (nextBuffer: Math.addExact at :199)
  • lucene/core/src/java/org/apache/lucene/index/FlushByRamOrCountsPolicy.java:45-56 (at 5adff78bb168) (deletes applied when they reach the RAM buffer)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:586, (at 5adff78bb168) :2248, :3809, :4402 (entry points into flushAllThreads / doFlush)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3758-3771 (at 5adff78bb168) (flushNextBuffer: only an Error is tragic); DocumentsWriter.java:246-258 (flushOneDWPT -> doFlush), :400-410 (postUpdate -> doFlush)
  • https://github.com/apache/lucene/issues/9738 (description: Solr decides from getTragicException()), https://github.com/apache/lucene/issues/7279, https://github.com/elastic/elasticsearch/issues/160855, https://github.com/apache/lucene/issues/10661
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-165 (at 5adff78bb168) (class javadoc), :2213-2214 (forceMerge), :2248, :4202-4209 (commit), :6108-6109 (getTragicException), :6242 (flushFailed TODO)
  • lucene/CHANGES.txt:11614-11616 (at 5adff78bb168) (LUCENE-5987)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterExceptions.java:943-970 (at 5adff78bb168) (testExceptionDuringSync); TestIndexWriterDelete.java:802 (@Ignore)
  • lucene/core/src/java/org/apache/lucene/index/TwoPhaseCommit.java:31 (at 5adff78bb168)
  • https://github.com/apache/lucene/issues/9738 (s1monw, 2019-03-06 and 2019-03-11), https://github.com/apache/lucene/issues/7049
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:62-64 (at 5adff78bb168) (isAborted), :98-115 (abort), :594-604 (maybeAbort); index/IndexWriter.java:437-444 (flush notifications only enqueue events)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:6067-6083 (at 5adff78bb168) (onTragicEvent records only), :6097-6104 (maybeCloseOnTragicEvent), :3882-3883 (prepareCommitInternal's finally)
How it was found
  • https://github.com/apache/lucene/pull/16867
Prior reports
  • https://github.com/apache/lucene/issues/9738, https://github.com/elastic/elasticsearch/issues/160855, https://github.com/apache/lucene/issues/14261, https://github.com/apache/lucene/issues/10661, https://github.com/apache/lucene/issues/7049, https://github.com/apache/lucene/issues/4106
Diagrams
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:502-581; (at 5adff78bb168) index/DocumentsWriterPerThread.java:427-439

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

Repro files

About the diagrams

A step-by-step picture of the repro, made in parallel with this doc from the code at 5adff78bb168 and executed runs; the PNG is the summary still. The diagram's fix scene shows the recommended fix A.

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.