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.
- 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.
- To flush a segment,
doFlushasksprepareFlushfor 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. - 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.

- Commit one document whose
idis a 1 KB term (victim). Add a documentbuffered; it waits in its own in-memory segment. - Call
updateNumericDocValue(victim, "n", i)2,200,000 times, thendeleteDocuments(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). commit()throwsArithmeticException: integer overflow: preparing the in-memory segment copies all 2.2 million updates sincebufferedinto its own buffer, about 2.25 GB, past the 2 GiB a byte pool can address.isOpen()istrue,getTragicException()isnull.- 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.
- Instead the next
commit()succeeds and returns 2,200,008, yetbufferedis missing,victimis still live, and itsnis 2,197,624: the document, the delete and the last 2,376 updates are gone.
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.
// 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
...
}- The thread that added
bufferedthen only updates and deletes, so its in-memory segment's slice spans all 2,200,001 later operations.commit()reachesdoFlushthroughprepareCommitInternal(IndexWriter.java:3809) andflushAllThreads/maybeFlush(DocumentsWriter.java:688,:496);flush(),forceMerge()(IndexWriter.java:2248,:4402) and an NRT reopen (:586) take the same path. freezeGlobalBufferapplies the global slice, builds aFrozenBufferedUpdatespacket and clears the writer-wide buffer (DocumentsWriterDeleteQueue.java:217-237,:262-277, clear at:272). Here the packet holds the delete ofvictimand 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).- 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.addExactinByteBlockPool.nextBufferthrowsArithmeticExceptionat 2 GiB (ByteBlockPool.java:199). addTicketgives its ticket count back and rethrows (DocumentsWriterFlushQueue.java:48-50).ticketis stillnull, so nothing holds the packet, and thefinallydrops the DWPT withbuffered.doFlushcalls neitherflushFailed,abort()noronTragicEventfor 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).- Contrast:
DocumentsWriterPerThread.flushtreats anyThrowableas aborting (:585-587), andmaybeAbortaborts 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, andflush's first InfoStream message ("flush postings as segment",:479-483, before thetryat:485). A non-tragic exception there is dropped the same way; the ticket already exists then, so only the segment's documents are lost.
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 defaultcommitOnClose(the try-with-resources idiom), aftercommit(),flush(),forceMerge(), an NRT reopen orflushNextBuffer()threw from this step. The writer gives no signal to stop:isOpen()istrueandgetTragicException()isnull, the two signals LUCENE-6217 (#7279) added for that decision (#9738's description says Solr's leader election, for one, decides fromgetTragicException()). Earlier commits stay intact, androllback()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; andclose()after a failedcommit(), which returns normally and commits the same incomplete state. By code reading,flush(), NRT reopen,IndexWriter.flushNextBuffer()(IndexWriter.java:3758-3771, which treats only anErroras 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 samedoFlushpath. - 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 makesprepareFlush's freeze throw (LFV-1046, 9.9.0 and later). (3) AnInfoStreamwhosemessage()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-neutralmain()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-tragicIllegalStateExceptionout offreezeGlobalBuffer<-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()orclose()fails the internalpendingNumDocs ... != ... totalMaxDocassertion (#10661 is the generic report).
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.
/*
* 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();
}
}
}
}
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 hasbuffered=1 victim=0. - Observed
ComparisonFailure: expected:<buffered=[1 victim=0]> but was:<buffered=[0 victim=1]>, with assertions on and off.
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.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT); this test also fails on the released 10.6.0 and 9.8.0, assertions on and off |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux, aarch64 |
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=2197624commit()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).forceMergepromises "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 withflush(true, true)(:2248); executed, it throws the sameArithmeticException, 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 flushforceMergestarts 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 everyThrowable(DocumentsWriterPerThread.java:585-604);IndexWriter.flushFailedcarries "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.testErrorAfterApplyDeletesstates the same expectation but has been@Ignored since 2017,TestIndexWriterDelete.java:802.) - History. Before #12392 (9.8.0) the same workload overflowed later, in
FrozenBufferedUpdatesapplied throughIndexWriter.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.
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 theArithmeticExceptionas its tragic exception. - The InfoStream check test passes with assertions on and off.
org.apache.lucene.indextest 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.
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.
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.
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.
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()throwingArithmeticExceptionthere, 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.
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 (notablyNoSuchFileException), 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) listsdoFlush()among methods whose non-tragic exceptions "might cause documents go missing", withrollback()as the remedy. A general design stance: no mechanism, noprepareFlush, 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-tragicIllegalStateException("buffer was finished already")out ofprepareFlushduring 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
OutOfMemoryErrorinsideDeleteSlice.apply<-prepareFlush; an Error is tragic), #10661 (thependingNumDocs != totalMaxDocassertion after a tragic flush), #7049 (LUCENE-5987, the self-close rule), #4106 (LUCENE-3032, an NPE inprepareFlushfixed by a null guard), #12392, #12647, #14913, #16867 (the code history).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive); Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA; web | 2026-11-02 | a scripted prior-art run of 60 queries (142 distinct hits) plus manual GitHub, list and JIRA searches | only the related items above |
| Open PRs | 2026-11-02 | the changed lines of all 410 open apache/lucene PRs | 25 touch the files involved; none changes prepareFlush, doFlush's failure path, freezeGlobalBuffer or onTragicEvent |
| Re-check (two independent passes, then this doc) | 2026-11-03 | 22 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 publishing | 2026-11-03 | 5 apache/lucene issue and PR searches (prepareFlush, freezeGlobalBuffer, markTicketFailed, tragic flush commit lost, doFlush tragic) and every apache/lucene item updated since 2026-11-03 | nothing new (only #14261 and #4106, listed above) |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 4.0.0 to 9.0.0 | InfoStream trigger | a 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 fault | a fault injected right after the freeze loses the document and the delete; no public-API trigger of this step known |
| 9.7.0 | fails loudly | this workload: the commit throws ArrayIndexOutOfBoundsException -65536 and the writer closes itself; nothing lost |
| 9.8.0 | first: 9.8.0 | this test fails, assertions on and off |
| 9.9.0, 9.12.3, 10.0.0, 10.5.2 | affected | an earlier form of the test (separate update target) or an independent test fails |
| 10.6.0 | affected | this test fails, assertions on and off |
main 5adff78bb168 | affected | this test fails through gradle, on and off; passes with fix A |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | found | One-test JUnit fails on main (on, off); the model-based fault injection: every fired fault breaks the oracle, 0 with the fix. |
| independent reproducer | real | Own 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), Medium | Re-ran the reproducer's test with a new seed, assertions off: fails, the writer open with no tragic exception. |
| this doc (gradle) | reproduced, fix validated | The 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. |
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 572 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,902 words
- 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 wholeorg.apache.lucene.indextest package (216 classes, 1,399 tests, 0 failures) and:lucene:core:check -x testpass, 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.
- 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
- TestFailedPrepareFlushLosesOps.java (one test, lucene/core):
repro/LFV-1078/TestFailedPrepareFlushLosesOps.java(63 lines)
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.
assets/LFV-1078/bug.png(572 KB)
