All findings · Lucene finding · LFV-1077 · idle-segment-delete-slice-pins-delete-queue

One buffered document makes IndexWriter keep every later delete and doc-values update on the heap until OutOfMemoryError

Add one document to an IndexWriter, then only delete or update by term with no commit, refresh or flush(): that document's in-memory segment keeps every later delete reachable, so the heap grows about 127 bytes per delete until OutOfMemoryError while ramBytesUsed() stays under 15 MiB. Default config, since 4.0.

Summary

Priority
Low, rank 287On the default config of every release since 4.0, a long run of only deletes or doc-values updates while some added document is still buffered keeps every delete reachable, outside the RAM accounting, until OutOfMemoryError. It needs millions of calls per GB of heap with no commit, refresh or flush, so it is rare and Low.
Impact
crash
Since
4.0.0-ALPHA (2012): per-thread writers, LUCENE-3023 2a969f828c1 (2011-05-01)introduced 2011-05-01 · over 15 years
Affected
4.0.0-ALPHA to 10.6.0 and main; default config
Status
documented
Found by
non-formal: a heap-versus-RAM-buffer probe, then code reading and a bounded model-based enumeration

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

Each in-memory segment keeps a pointer into the writer's shared delete queue, a linked list of every delete. The pointer moves only when that segment indexes another document or flushes. Deletes alone never flush it, so everything after the pointer stays on the heap, and nothing counts it.

Background: in-memory segments and the delete queue

  1. IndexWriter buffers new documents in in-memory segments, one per indexing thread, and writes a segment to disk when the RAM buffer fills (setRAMBufferSizeMB, 16 MB by default) or on commit(), an NRT reopen or flush().
  2. Deletes and doc-values updates by term go into one shared queue. A buffered segment must apply the deletes that arrive after its documents when it flushes, so it remembers where the queue ended when its last document finished.
  3. When the buffered deletes reach the RAM buffer, the writer pushes them: it applies them to the segments already on disk and drops its own copy.
Diagram of the bug: an IndexWriter with one buffered document in an in-memory segment. That segment's delete-slice pointer stays where the queue ended when its document finished (the node before the first later delete) while deletes are appended to the shared delete queue. Each push of the global delete buffer frees the writer's applied copy, but every node after the pointer stays reachable, so the heap grows while ramBytesUsed() stays below the RAM buffer.
One buffered document's segment keeps its pointer into the delete queue while deletes pile up behind it: each push frees the writer's own copy of the deletes, but the chain behind the pointer keeps growing. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Open an IndexWriter with the default config (16 MB RAM buffer) and add one document. Do not commit.
  2. Call deleteDocuments(new Term("id", id)) 2.5 million times with new 16-character ids, in a 256 MB heap.
  3. You expect the heap to stay near the RAM buffer: without that one document the same stream stays at 10-22 MiB, because every 16 MB of buffered deletes is applied and dropped.
  4. Instead the heap grows from 42 MiB at 250,000 deletes to 224 MiB at 1.75 million, then OutOfMemoryError. ramBytesUsed() says 5.6-14.2 MiB the whole time.
  5. Why: the segment holding the document keeps a pointer into the writer's linked list of deletes, and everything after it stays reachable. The pointer moves only when that segment indexes again or flushes, and deletes alone never flush it.

Who is affected

Default config, one thread, public API. It hits a writer with a buffered document that then runs a long delete-only or doc-values-update-only stretch, such as a bulk purge or backfill, with no commit, refresh or flush(): about 8-10 million 16-byte-id deletes per GB of heap. It ends in OutOfMemoryError, not wrong results.

Mechanism, terms and numbers763 words

Every in-memory segment of an IndexWriter (a DocumentsWriterPerThread, DWPT) keeps a private DeleteSlice: a pointer into the writer's shared delete queue, a singly linked list of every delete and doc-values update. The pointer moves only when the segment indexes another document or is flushed. After one addDocument, a stream of only deleteDocuments(Term) or doc-values updates therefore keeps every later queue node, with its Term or update, strongly reachable.

The RAM buffer never frees them. A pure delete reaches the flush policy with no per-thread writer, so FlushByRamOrCountsPolicy can only push the global delete buffer, which frees the writer's own copy but not the chain behind the idle segment's pointer, and IndexWriter.ramBytesUsed() counts only that global buffer (reset at every push) plus the segments' own bytes. With the default config and 16-character ids the heap grows about 127 bytes per delete until OutOfMemoryError (256 MB heap: 42 MiB at 250,000 deletes, 224 MiB at 1.75 million) while ramBytesUsed() stays at 5.6-14.2 MiB; with no buffered document the same stream stays at 10-22 MiB. updateNumericDocValue and updateSortedNumericDocValue behave the same way.

Keeping these deletes until the segment flushes is required (its document must see them); the defect is that the kept span is unbounded, uncounted and never released by the RAM buffer. It came with per-thread writers in 4.0.0-ALPHA (3.6.2 flushes the document with the deletes) and is in every release up to 10.6.0 and main. commit(), an NRT reopen or flush() releases it.

Terms

  • DWPT: DocumentsWriterPerThread, one in-memory segment. numRamDocs() counts the documents buffered in all of them.
  • Delete queue: DocumentsWriterDeleteQueue, a singly linked list of nodes, each holding a Term[], Query[] or DocValuesUpdate[]. The class javadoc: "the garbage collector takes care of pruning the list for us. All nodes in the list that are still relevant should be either directly or indirectly referenced by one of the DWPT's private DeleteSlice or by the global BufferedUpdates slice" (DocumentsWriterDeleteQueue.java:42-45 at e1faeba08bde).
  • Delete slice: DeleteSlice (DocumentsWriterDeleteQueue.java:333-372), a head and a tail pointer into the queue. Each DWPT has one (DocumentsWriterPerThread.java:134, :165); the global buffer has another.
  • Push: DocumentsWriter.applyAllDeletes freezes the global buffer into a packet that is applied to the on-disk segments, and clears it (DocumentsWriter.java:168-186, DocumentsWriterDeleteQueue.java:262-277).

The bug

  1. After a document is added, finishDocuments moves the DWPT's slice so that sliceHead is the queue's tail at that moment: the queue node before the first later delete (in the diagram probe, the sentinel) (DocumentsWriterPerThread.java:361-390). Only this and prepareFlush at flush time (:427-439) move a live DWPT's slice.
  2. Each deleteDocuments(Term) appends a node and advances only the global slice (DocumentsWriterDeleteQueue.java:129-139, :198-215). Through Node.next (:411-412) the idle DWPT's head references every later node.
  3. DocumentsWriter.applyDeleteOrUpdate (DocumentsWriter.java:153-165) calls flushControl.doOnDelete(), which passes no DWPT (DocumentsWriterFlushControl.java:497-501). In FlushByRamOrCountsPolicy.onChange every branch that marks a segment for flushing needs one, so only flushDeletes runs (FlushByRamOrCountsPolicy.java:40-57).
  4. The push frees the global copy only. DocumentsWriterDeleteQueue.ramBytesUsed() is the global buffer (:557-560), reset at each push, and IndexWriter.ramBytesUsed() is that plus the DWPTs' own bytes (DocumentsWriterFlushControl.java:508-516, IndexWriter.java:795-799). None of these counts the chain, and no delete or update call flushes the idle segment; flush(), commit(), an NRT reopen, flushNextBuffer() or a later document in that segment move its slice. A later document does not free the span: finishDocuments copies it into the segment's own, counted pending deletes (DocumentsWriterPerThread.java:380-383, :718-722).

The same path serves deleteDocuments(Query...), updateNumericDocValue, updateBinaryDocValue, updateSortedNumericDocValue and updateDocValues(Term, Field...) (IndexWriter.java:1864-2075); the exception is a deleteDocuments(Query...) call with an exact MatchAllDocsQuery, which becomes deleteAll() (:1886-1890) and drops the buffered document too. Executed: Term deletes, numeric and sorted-numeric updates.

The numbers (default config, 256 MB heap, assertions off, 16-character ids)

deletesheap after GC, one buffered documentramBytesUsed()heap after GC, no document
250,00042 MiB7.1 MiB12 MiB
500,00083 MiB14.2 MiB22 MiB
1,000,000144 MiB13.8 MiB21 MiB
1,750,000224 MiB5.6 MiB10 MiB
2,500,000OutOfMemoryError after 1.75M-18 MiB, completes

One buffered document on e1faeba08bde; the no-document column is the independent reproducer's control on 645d4fe14a20 (same fix-site code apart from #16867's global cap). These are measurements of this workload (OpenJDK 25.0.3, ByteBuffersDirectory, ids String.format("%016d", i)), not an allocation contract. updateNumericDocValue with one document: 51 to 253 MiB over 250,000 to 1.5 million updates, then OutOfMemoryError, ramBytesUsed() at most 13.9 MiB.

Exposure and likelihood260 words
  • Who is hit. Applications whose IndexWriter still holds at least one added document in RAM and then sends only deletes or doc-values updates for a long time without commit(), an NRT reopen or flush(): a bulk purge by id, a doc-values backfill, a tombstone replay. A buffered document is the normal state right after indexing.
  • Default vs opt-in. Default IndexWriterConfig; nothing to opt into. One thread is enough.
  • Scale. About 110-130 bytes of heap per 16-byte-id delete (127 in the probe), so roughly 8-10 million such calls per GB of heap. The growth is linear and ramBytesUsed() stays under the buffer, so an application that watches ramBytesUsed() (as the flushNextBuffer javadoc suggests, IndexWriter.java:3745-3757) cannot see it.
  • How it ends. OutOfMemoryError, in application code or inside a writer call; in the latter case the writer treats it as tragic and closes, and uncommitted changes may be lost. No wrong results and no damage to the committed index.
  • Why it is rare. Applications that refresh or commit periodically release the span each time, and nobody has reported it since 4.0 (2012). Workaround: flush(), commit() or a refresh every few hundred thousand deletes or updates.
  • Not claimed. When the idle segment finally flushes it copies the whole pinned stream into its private buffer; at 2 GiB of delete terms that throws an "integer overflow" ArithmeticException from prepareFlush (a separate run with an 8 GB heap). Several idle segments after multi-threaded indexing pin the same nodes (no multiplication) but each copies the stream at flush (code reading only).

Minimal repro

One JUnit test on the public API: a 1 MB RAM buffer, one buffered document, a weak reference to the first delete term, 100,000 more term deletes, then System.gc(). One thread, about two seconds; on stock code it fails every run. Remove the addDocument line and it passes.

TestIdleSegmentPinsAppliedDeletes.java (one test, public API)48 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.lang.ref.WeakReference;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestIdleSegmentPinsAppliedDeletes extends LuceneTestCase {

  // Expected: deletes pushed past the 1 MB RAM buffer are released; actual: one buffered doc keeps them reachable.
  public void testBufferedDocumentPinsPushedDeletes() throws Exception {
    try (Directory dir = newDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setRAMBufferSizeMB(1))) {
      Document doc = new Document();
      doc.add(new StringField("id", "buffered", Field.Store.NO));
      w.addDocument(doc);
      Term first = new Term("id", "x0");
      WeakReference<Term> firstRef = new WeakReference<>(first);
      w.deleteDocuments(first);
      first = null;
      for (int i = 1; i <= 100_000; i++) {
        w.deleteDocuments(new Term("id", "x" + i));
      }
      for (int i = 0; i < 10 && firstRef.get() != null; i++) {
        System.gc();
      }
      assertNull("first delete term still reachable after 100,000 deletes", firstRef.get());
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at e1faeba08bde
cp $REPRO/TestIdleSegmentPinsAppliedDeletes.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestIdleSegmentPinsAppliedDeletes -Ptests.seed=5EED1077
# control: delete the w.addDocument(doc) line and the test passes
Expected
Deletes pushed past the 1 MB buffer are applied and dropped, so the first delete term becomes garbage and the weak reference is cleared.
Observed
AssertionError: first delete term still reachable after 100,000 deletes expected null, but was:<id:x0>, with assertions on and off.

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

More files, environment, full output292 words

The smallest input: 100,000 deletes cross the 1 MB buffer at least twice (with the same buffer and ids, the diagram probe sees the first push at delete x44506), one StringField document is the least that leaves a segment buffered, and the weak reference to the first Term is the direct oracle for "still reachable after it was applied". Assertions on and off give the same failure. The same test is the regression test to add with a fix.

  • On stock code the weak-reference check fails every time, since the GC never clears a strongly reachable referent; a fixed build passes only if System.gc() clears the reference (upstream removed a similar test in #12223). For a fix that flushes, assertEquals(0, w.numRamDocs()) after the loop is a GC-free alternative assertion.
  • The comment line is longer than 100 columns, so ./gradlew tidy would reformat it.

Environment

lucenemain e1faeba08bde (12.0.0-SNAPSHOT); the same failure on the 10.6.0, 10.5.2 and 9.12.3 jars
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# gradle, stock e1faeba08bde, seed 5EED1077, assertions on
TestIdleSegmentPinsAppliedDeletes > testBufferedDocumentPinsPushedDeletes FAILED
    java.lang.AssertionError: first delete term still reachable after 100,000 deletes expected null, but was:<id:x0>
        at org.apache.lucene.index.TestIdleSegmentPinsAppliedDeletes.testBufferedDocumentPinsPushedDeletes(TestIdleSegmentPinsAppliedDeletes.java:45)

# standalone JUnit runner, stock e1faeba08bde, assertions on and off: the same AssertionError
# control without addDocument, assertions off, 3 runs: pass
# with the recommended fix, gradle, seed 5EED1077, assertions on and off: TestIdleSegmentPinsAppliedDeletes, tests=1 failures=0

# supporting heap probe (not the repro): default config, -Xmx256m, assertions off, one buffered document; every sample line
mode=doc ops=250000 heapAfterGC=42MiB ramBytesUsed=7.1MiB numRamDocs=1
mode=doc ops=500000 heapAfterGC=83MiB ramBytesUsed=14.2MiB numRamDocs=1
mode=doc ops=750000 heapAfterGC=103MiB ramBytesUsed=6.6MiB numRamDocs=1
mode=doc ops=1000000 heapAfterGC=144MiB ramBytesUsed=13.8MiB numRamDocs=1
mode=doc ops=1250000 heapAfterGC=164MiB ramBytesUsed=6.1MiB numRamDocs=1
mode=doc ops=1500000 heapAfterGC=202MiB ramBytesUsed=13.2MiB numRamDocs=1
mode=doc ops=1750000 heapAfterGC=224MiB ramBytesUsed=5.6MiB numRamDocs=1
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

Why it looks unintended

  • setRAMBufferSizeMB: "the writer will flush whenever buffered documents and deletions use this much RAM". Here they use unbounded, uncounted RAM.
  • Without the buffered document the same stream stays at 10-22 MiB, and 3.6.2 releases it: a 4.0 regression.
  • "Deleted terms won't trigger a segment flush" (IndexWriter javadoc) contrasted the removed delete-count setting with the RAM buffer.
The full argument403 words
  • The contract. LiveIndexWriterConfig.setRAMBufferSizeMB (LiveIndexWriterConfig.java:157-165 at e1faeba08bde): "Determines the amount of RAM that may be used for buffering added documents and deletions before they are flushed to the Directory" and "the writer will flush whenever buffered documents and deletions use this much RAM".
  • No place in the internal accounting. DocumentsWriterFlushControl.getDeleteBytesUsed (:503-510): "heap bytes currently consumed by buffered deletes/updates that would be freed if we pushed all deletes". So it counts only what a push frees, the global buffer (a push does free it: in the heap probe ramBytesUsed() drops from 14.2 to 6.6 MiB between 500,000 and 750,000 deletes). The queue nodes an idle slice keeps are freed by no push, so they are counted nowhere. (Its next sentence excludes "already pushed delete/update packets", the frozen packets, not those nodes.)
  • The hedges do not cover it. "just an indicator" is explained by concurrent flushes; the "only approximate" note names delete-by-Query only (LiveIndexWriterConfig.java:167-177). This is Term deletes, one thread, linear growth. The third note (:179-181: "only a subset of the buffered documents are flushed and therefore only parts of the RAM buffer is released") is about which buffered documents a flush writes; here no document is ever flushed, and the pinned span is never part of the counted buffer.
  • Bounded without the document; released in 3.x. The no-document control stays at 10-22 MiB and the minimal test without its addDocument passes. In 3.1.0 and 3.6.2 the same workload flushes the buffered document together with the deletes and frees them.
  • Precedents fixed as bugs. LUCENE-1717 (#2791, uncounted pending-delete RAM: "it's a trap, now. We shouldn't set traps."), LUCENE-3222 (delete-term undercount), LUCENE-3340 (#4413, buffered deletes not flushed by RAM, called a memory leak, its tests "fail on trunk and pass on 3.x"), LUCENE-9478 (#10517, "DWPTDeleteQueue leaks memory", a delete-queue retention leak by another mechanism) and LUCENE-9298 (#10338, RAM accounting in BufferedUpdates when deleted doc IDs and terms are cleared).
  • Design discussion. On #3647 (LUCENE-2573, 2011) McCandless wrote that the flush policy "needs access to the 'global' deletes count (and also the to [sic] per-DWPT deletes count). If a given DWPT has 0 buffered docs, then indeed the buffered deletes in its pool doesn't matter". Here it has one.
  • No test asserts the retention. TestIndexWriterDelete.testIndexingThenDeleting, testFlushPushedDeletesByRAM and testApplyDeletesOnFlush never leave an idle buffered document; the numRamDocs() > 0 asserts of TestPerSegmentDeletes are in a commented-out block (:102-158).
The devil's-advocate check246 words

The strongest case for "intended" (all the reviews from another model family rated it moderate; the devil's-advocate verifier called the javadoc sentence the main risk, with medium confidence on how upstream will receive it): the IndexWriter class javadoc says "In contrast to the other flush options setRAMBufferSizeMB and setMaxBufferedDocs, deleted terms won't trigger a segment flush" (IndexWriter.java:129-131); the 4.0 CHANGES entry says "IndexWriter only applies and flushes deletes if the maxBufferedDelTerms limit is reached during indexing. No segment flushes will be triggered due to this setting." (lucene/CHANGES.txt:15781-15783); the dev@ thread "FlushPolicy and maxBufDelTerm" (2013-08-01) accepted that delete pressure does not write a segment; the kept deletes really are needed until the segment flushes; and the RAM limit is "just an indicator".

Why it fails: the javadoc sentence was added in f47c3e82985 (LUCENE-3023, 2011-04-28) right after a sentence about setMaxBufferedDeleteTerms, contrasting that count setting with the RAM buffer; 58105a203a1 (LUCENE-7868, 2017) removed the count sentence and left this one. The CHANGES entry and the 2013 thread are about the same count setting ("due to this setting"), and in that thread McCandless wrote "if RAM usage of the buffered del Term/Query s is too high, then a segment is flushed along with the deletes being applied". Nothing documents unbounded, uncounted retained heap; "needed until the flush" is why the slice exists, not a reason never to flush or count it. The fix does not need a segment flush per delete (see Suggested fix).

Suggested fix

Recommended Flush idle in-memory segments when a pure delete or update pushes the global delete buffer

When a pure delete or doc-values update pushes the global delete buffer, flush the in-memory segments that are idle (only those it can tryLock, never one an indexing thread holds). It sits in DocumentsWriter.applyDeleteOrUpdate, so every delete and update call is covered. +45/-1 in two files; the repro passes, and 356 tests of 17 IndexWriter suites pass on two seeds.

private long applyDeleteOrUpdate(ToLongFunction<DocumentsWriterDeleteQueue> function)
    throws IOException {
  final long seqNo = applyDeleteOrUpdateLocked(function); // the old synchronized body
  if (seqNo < 0) { // this call pushed the global delete buffer
    DocumentsWriterPerThread idle;
    while ((idle = flushControl.checkoutIdleWriterWithDocs()) != null) {
      doFlush(idle); // prepareFlush applies and drops the idle slice
    }
  }
  return seqNo;
}
All options and trade-offs4 options ยท 946 words

Option A was written for this doc and validated through gradle in a scratch worktree of e1faeba08bde (OpenJDK 25.0.3). Option C is the hunt's earlier sketch; B and D were not implemented.

Flush idle in-memory segments when a pure delete or update pushes the global delete buffer (validated by the checks) (recommended)

DocumentsWriter.applyDeleteOrUpdate (DocumentsWriter.java:153-165) keeps its synchronized body as applyDeleteOrUpdateLocked; when that returns a negative sequence number (this call pushed the global buffer, :161-163), it flushes, outside the lock, every DWPT returned by a new DocumentsWriterFlushControl.checkoutIdleWriterWithDocs(). That method takes only DWPTs that have documents, are not flush-pending and can be tryLocked, marks one pending and checks it out, like checkoutLargestNonPendingWriter. The flushed segment is published by the caller's IndexWriter.maybeProcessEvents, which already runs for a negative sequence number (IndexWriter.java:6345-6351). +45/-1 in DocumentsWriter.java and DocumentsWriterFlushControl.java.

private long applyDeleteOrUpdate(ToLongFunction<DocumentsWriterDeleteQueue> function)
    throws IOException {
  final long seqNo = applyDeleteOrUpdateLocked(function); // the old synchronized body
  if (seqNo < 0) { // this call pushed the global delete buffer
    DocumentsWriterPerThread idle;
    while ((idle = flushControl.checkoutIdleWriterWithDocs()) != null) {
      doFlush(idle); // prepareFlush applies and drops the idle slice
    }
  }
  return seqNo;
}

Trade-offs

  • Bound. For every idle DWPT the push can lock, the kept span ends at that push, so it stays within about one RAM buffer of deletes. Skipped DWPTs keep theirs: one whose tryLock() fails (an indexing thread holds it; its slice moves at its next document) or one already flush-pending. Nothing retries them, so a skipped DWPT that then stays idle keeps its span until a later push finds it free. The span is also still uncounted before the first push: in the heap probe the heap reached 83 MiB at 500,000 deletes (16 MB buffer, almost 6 times the counted 14.2 MiB) before the first push released it.
  • Cost. A delete or update call that pushes may now write segments, so it does flush I/O and can throw what a flush throws, as addDocument already can. The flush runs after the delete is queued and pushed, so an exception from it reaches the caller of a delete that has already taken effect; that needs a decision upstream. Each push flushes each idle DWPT it can lock once, and doFlush then also flushes any other DWPT already pending (DocumentsWriter.java:502-580), as the add path does. An idle DWPT's segment holds whatever was buffered when indexing paused; segment sizes and counts were not measured, and no throughput benchmark was run. It happens only when the global delete buffer alone reaches the RAM buffer; with documents being added, the add path's activeRam + deletesRam check usually flushes first (FlushByRamOrCountsPolicy.java:54-55, by reading). With RAM flushing disabled (DISABLE_AUTO_FLUSH), pushes happen only at #16867's 1945 MB cap, so the fix would act only there (by reading).
  • Concurrency. A DWPT held by an indexing thread is skipped. A push never happens during a full flush (DocumentsWriter.java:175-176). If a full flush begins between the push and the checkout, checkout marks nothing: it returns null for a DWPT that is not flush-pending, and for one that is it blocks it and returns the next pending flush (DocumentsWriterFlushControl.java:261-280), the same handling checkoutLargestNonPendingWriter (:789-810, used by flushNextBuffer()) relies on; the pending flag is re-read under the flush-control lock. No concurrency or full-flush test of this path was run.
  • Docs. The IndexWriter javadoc sentence "deleted terms won't trigger a segment flush" would need updating.

What the checks validated

  • The minimal test fails on stock e1faeba08bde (gradle, seed 5EED1077) and passes with the fix, assertions on and off (gradle on a copy of e1faeba08bde with the diff applied; the JUnit XML names TestIdleSegmentPinsAppliedDeletes; the fact check's standalone JUnit runs agree).
  • The independent root-cause reviewer's 3-test class (Term deletes, the no-document control and updateSortedNumericDocValue, which option C misses) passes with the fix.
  • 17 closest suites (TestIndexWriterDelete, TestFlushByRamOrCountsPolicy, TestDocumentsWriterDeleteQueue, TestDocumentsWriterStallControl, TestNumericDocValuesUpdates, TestBinaryDocValuesUpdates, TestMixedDocValuesUpdates, TestIndexWriter, TestIndexWriterExceptions, TestIndexWriterWithThreads, TestIndexWriterReader, TestStressDeletes, TestIndexWriterThreadsToSegments, TestPerSegmentDeletes, TestIndexWriterMaxDocs, TestForceMergeForever, TestSoftDeletesRetentionMergePolicy): 356 tests, 16 skipped, 0 failures, seeds 5EED1077 and C0FFEE77.
  • checkGoogleJavaFormat reports nothing in the two changed files; ecjLintMain passes.
  • Heap probe with the fix: Term deletes and updateNumericDocValue both complete 2.5 million calls in a 256 MB heap, 5-21 MiB after the first push, numRamDocs() 0.

Count the span an idle slice keeps and act on it (not implemented)

Track the bytes appended to the queue since each DWPT's slice head (for example a running total of the global buffer's bytes at each push, recorded when a slice moves), add it to getDeleteBytesUsed(), and let FlushByRamOrCountsPolicy flush or apply the idle DWPT once it passes the buffer.

Trade-offs

More precise than A: it also bounds the uncounted span before the first push and makes ramBytesUsed() honest. More code on hot paths, and it needs a per-node byte estimate.

Flush every DWPT from IndexWriter after a delete call that pushed (the hunt's sketch)

while (docWriter.flushOneDWPT()) {} after the push in five IndexWriter delete and update methods.

Trade-offs

Validates the root cause (the minimal test passes with assertions on and off), but misses updateSortedNumericDocValue, and flushOneDWPT takes the largest DWPT with a blocking lock, including ones indexing threads are using; the adjudication's summary of the reviews calls it too blunt (tiny segments).

Documentation only

Say in setRAMBufferSizeMB and the IndexWriter class javadoc that while a document is buffered, every later delete and update is kept until the next flush, commit or refresh, and recommend flush() during long delete-only or update-only runs.

Trade-offs

Cheapest; leaves the trap.

Downstream note

Services that run bulk purges or doc-values backfills through one long-lived writer should refresh, commit or flush() every few hundred thousand operations; a service that refreshes on a schedule already releases the kept deletes each time.

How it was found

Non-formal. A probe compared retained heap with the RAM buffer during a delete-only stream on the stock jar and saw unbounded growth only when one document was buffered; reading the delete-slice code gave the mechanism. A bounded model-based enumeration of writer operation sequences then matched "kept iff a document is buffered" in all 1,170 runs.

The method in full161 words

Method: non-formal (a measurement first, then code reading; the later bounded enumeration is semi-formal).

  • The hunt (2026-11-02) ran a heap-versus-RAM-buffer probe on the stock main jar: default config, one thread, -Xmx256m, one buffered document, then N deleteDocuments(Term) or updateNumericDocValue calls, sampling heap after GC and ramBytesUsed() every N/8 calls, against a control with nothing buffered. The idle run grew to OutOfMemoryError; the control stayed bounded.
  • A hunt unit read the DeleteSlice code for the mechanism and reduced it to one public-API JUnit test.
  • It then checked the prediction with a bounded model-based enumeration (every sequence of up to 3 of 8 writer operations before a delete or doc-values burst, 1,170 runs, assertions on and off): 380 runs kept the first term, all exactly those with numRamDocs() > 0 when the burst started, 0 mismatches. With its fix sketch as a vacuity control, 0 runs kept it.
  • Commit-level bisection and version ports traced it to the 4.0 per-thread writer landing.

Prior reports, history and checks

Prior reports

No prior report found (2026-11-02) in GitHub issues, PRs and commits, open PRs' changed files, the JIRA archive, dev@ and java-user@, downstream projects and the web. #14261, an undiagnosed nightly OOM in a stress test without pure deletes, is not shown to be this bug.

Searches3 searches

Context only: #3647 / LUCENE-2573 (2011 flush-policy design: per-DWPT deletes matter when the DWPT has buffered docs), open #3753 / LUCENE-2679 (per-session delete RAM, 2010), #12572 and #12595 (flushNextBuffer and the global buffer), #16867 (global delete-buffer cap, 2026-10-30), dev@ "FlushPolicy and maxBufDelTerm" (2013, about the count setting). Fixed precedents at other sites: LUCENE-1717, -3222, -3340, -9298, -9478. #14261's trace allocates in prepareFlush -> DeleteSlice.apply, but its workload has no pure deletes.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@; JIRA archive; the web2026-11-02a scripted prior-report search run with 51 queries (220 hits, 0 high) and 29 manual GitHub searches (DeleteSlice, sliceHead, idle DWPT, pure deletes, deleteDocuments OutOfMemoryError, DocValuesUpdatesNode, updateNumericDocValue heap and others), on top of the finder's 102 queries and the independent reproduction's and the intended-behaviour check's searchesno report; nearest #14261 (not shown to be this bug)
Open and recent apache/lucene PRs and issues2026-11-02changed files of 30 open PRs touching IndexWriter or DocumentsWriter files; the 13 PRs updated since 2026-11-01; latest commits to the five fix-site filesnone changes DeleteSlice, the idle-segment path or the no-DWPT flush branch; #16867 caps only the global buffer
JIRA archive2026-11-02comments of LUCENE-2956, -3023, -2573, -9304, -3222, -9298, -2324design context only

History

Introduced with per-thread writers: LUCENE-3023 2a969f828c1 (2011-05-01), found by commit-level bisection, with the mechanism from LUCENE-2956 e95097a7c8d. First released in 4.0.0-ALPHA; 3.6.2 releases the deletes. Fails on every executed release up to 10.6.0 and still on main e1faeba08bde.

Versions tested7 rows

Introduced by 2a969f828c1 "LUCENE-3023: Land DWPT on trunk" (2011-05-01, Subversion trunk r1098427, merge commit; no pull request). Its parent 0528549d178 flushes the buffered document and releases the term; 2a969f828c1 keeps the term with numRamDocs=1, assertions on and off. The per-DWPT DeleteSlice and the "garbage collector takes care of pruning" javadoc came from e95097a7c8d, LUCENE-2956 "Support updateDocument() with DWPTs" (#4030, 2011-04-13); later commits to the slice code are refactors only.

Still reproduces on main. On main e1faeba08bde the fix-site files differ from the hunt's 645d4fe14a20 only by #16867 (2813efa40a3, 2026-10-30), which caps the global delete buffer at 1945 MB (DocumentsWriterFlushControl.java:45-53, checked at FlushByRamOrCountsPolicy.java:35-39) and stalls indexing during a full flush when the cap is reached (DocumentsWriterFlushControl.java:326-329); it does not touch DeleteSlice, the idle segment or ramBytesUsed(), and the minimal test still fails.

Versions and branches executed, with results
Version or commitVerdictResult
main e1faeba08bde affectedthe minimal test fails through gradle and the standalone JUnit runner, assertions on and off; heap probe to OutOfMemoryError
main 645d4fe14a20 affectedthe finder's, the independent reproducer's and the root-cause review's tests fail, assertions on and off
10.6.0, 10.5.2, 9.12.3 affectedthe JUnit fails on the release jars
8.11.4, 7.7.3, 6.6.6, 5.0.0, 4.10.4, 4.0.0, 4.0.0-ALPHA affecteda main() port keeps the term; its no-document control releases it
3.6.2, 3.1.0 not affectedthe buffered document is flushed with the deletes (numRamDocs=0) and the term is released
2a969f828c1 vs its parent 0528549d178 bisectthe landing keeps the term; its parent releases it
main e1faeba08bde + fix fixthe minimal test passes, assertions on and off; heap probe completes 2.5M deletes

Independent checks

Confirmed by the adjudication (medium confidence, about upstream reception) after an independent reproducer, an intended-behaviour devil's advocate and three executed reviews by another model family (OpenAI GPT-5.6), none dissenting; a credibility audit agreed; re-run on the newest main for this doc, with a fix validated.

Checks and verdicts325 words

Each checker that executed wrote or re-ran a test from the claim; the adjudication re-ran the independent reproducer's test with a new seed, assertions on and off.

Each independent check and its verdict
CheckVerdictWhat it found
finder (heap probe, JUnit, model-based enumeration) reproducedIts JUnit fails on main (assertions on and off, 10 of 10), 10.6.0, 10.5.2 and 9.12.3; the no-document control passes; 1,170 enumerated runs, 0 prediction mismatches.
refute review (OpenAI GPT-5.6) real, mediumExecuted. Its own 2-test class fails under 3 seeds, assertions on and off; its control passes; intended case moderate.
intended review (OpenAI GPT-5.6) real, highExecuted. Term deletes and numeric updates keep the first term while ramBytesUsed() stays under 8 MiB; no-document and commit controls release it; 3.6.2 stays bounded.
Independent reproducer realIts own test fails on main, 10.6.0, 10.5.2 and 9.12.3, assertions on and off; controls without a document and with flush() pass; heap probes reach OutOfMemoryError for deletes and numeric updates.
Root-cause review (OpenAI GPT-5.6) real, high; sketch incompleteTraced the same chain; found that the hunt's sketch misses updateSortedNumericDocValue.
Intended-behaviour devil's advocate realBuilt the javadoc, CHANGES and dev@ case and traced its origin; ramBytesUsed() is about the same with and without the buffered document (668,512 vs 602,792 bytes).
adjudication confirmed, mediumRe-ran the independent reproduction's test with seed 1A2B3C4D: fails, assertions on and off. Rated a crash (OutOfMemoryError), priority Low, with no security impact.
credibility audit confirmedThe audit confirmed it; a separate re-run of the minimal test fails with assertions on and off, passes with the sketch, and its control passes 5 of 5.
this doc real, fix validatedThe minimal test fails on main e1faeba08bde (gradle and the standalone JUnit runner, assertions on and off); the recommended fix passes it (gradle, the JUnit XML names the class), the root-cause reviewer's sorted-numeric test and 356 tests of 17 suites on two seeds.

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

Finding record

LFV-1077, snapshot of 2026-11-06

Registry title
While a document sits buffered in an idle in-memory segment, IndexWriter keeps every later delete and doc-values update reachable, uncounted by ramBytesUsed() and never released by the RAM buffer, so a delete-only or update-only stream grows the heap until OutOfMemoryError (default config, since 4.0.0)
Area
index / IndexWriter delete queue (DeleteSlice of an idle DocumentsWriterPerThread, flush-by-RAM on pure deletes)
Priority
Low, rank 287: On the default config of every release since 4.0, a long run of only deletes or doc-values updates while some added document is still buffered keeps every delete reachable, outside the RAM accounting, until OutOfMemoryError. It needs millions of calls per GB of heap with no commit, refresh or flush, so it is rare and Low.
Impact class
crash
Impact note
Hits an application on the default IndexWriterConfig that, while at least one added document is still buffered in RAM, issues a long delete-only or doc-values-update-only run (deleteDocuments(Term), updateNumericDocValue and the other update calls that take the same path) with no commit, NRT reopen or flush() in between: about 110-130 bytes of heap stay reachable per 16-byte-id delete (heap probes: 42 -> 224 MiB over 250k -> 1.75M deletes, then OutOfMemoryError in a 256 MB heap) while IndexWriter.ramBytesUsed() stays at 5.6-14.2 MiB, so roughly 8-10 million such calls per GB of heap; one thread suffices, applications that refresh or commit periodically release the span each time, and every release since 4.0.0 is affected. A second, bounded trigger (2026-11-02): after a phase in which two threads indexed at once, a single thread's ordinary updateDocument stream is pinned the same way by the second, now idle, in-memory segment (163 MiB retained vs 14 MiB in a control after 1.5M updateDocument calls with ramBytesUsed() at 5.3 MiB, OutOfMemoryError in 256 MB before 2.5M calls, default 16 MB buffer, tiny documents); the window closes at the active segment's first RAM flush or any full flush, so the overshoot is about 110 bytes per call times the calls that fill the RAM buffer: roughly 20-30 times the buffer for documents that take only a few bytes of indexing RAM each, and below the buffer for ordinary documents.
Summary
You add a document to an IndexWriter with the default config and then, without a commit, NRT reopen or flush(), send a long stream of deleteDocuments(Term) or doc-values update calls, and expect the heap held by those buffered deletes to stay around the RAM buffer, because the writer applies and drops them each time they reach it, as it does when nothing is buffered. Instead the in-memory segment that holds the one document keeps a pointer into the shared delete queue that moves only when that segment indexes again or is flushed, so every later delete stays reachable after it was applied. IndexWriter.ramBytesUsed() does not count it, nothing flushes that segment, and the heap grows with every call until OutOfMemoryError. The same happens without any delete-only run when two threads indexed at once and then one thread keeps calling updateDocument: the second in-memory segment stays idle and pins every later delete term until the active segment fills the RAM buffer and is flushed.
Introduced
2a969f828c1b65d81f0e630188dd8244694765b9 (trunk landing of per-thread writers, LUCENE-3023 'Land DWPT on trunk', Subversion trunk r1098427, a merge commit by Uwe Schindler; LUCENE-3023 = apache/lucene#4097; svn era, no pull request) [exec: commit-level bisection with lucene-core built from the history clone: the parent 0528549d178 releases the Term and flushes the buffered document, 2a969f828c1 keeps the Term reachable with numRamDocs=1, -ea and -da, controls pass]. The mechanism (DocumentsWriterDeleteQueue with a per-DWPT DeleteSlice whose head an idle writer never advances, and the class javadoc that relies on the GC to prune the list) came from e95097a7c8dfc042c09288717822768c6011f5e8, LUCENE-2956 'Support updateDocument() with DWPTs' (apache/lucene#4030) by Simon Willnauer on the realtime_search branch [read: git blame of DeleteSlice on main; the unfinished branch does not discriminate because its control also fails]. A 4.0 regression relative to 3.x, not a recent one. (2011-05-01 (2a969f828c1, author and commit date 2011-05-01T22:38:33Z, landing on trunk); mechanism e95097a7c8d authored 2011-04-13T09:20:39Z)
First affected version
4.0.0-ALPHA [exec: version-port main() probe on the release jar keeps the first Term reachable, its control releases it]; first GA release 4.0.0 [exec]; not affected: 3.6.2 and every earlier release, which have no per-thread writers [exec on 3.1.0 and 3.6.2: the buffered document is flushed with the deletes (numRamDocs=0) and the Term is released]
Affected versions
every release from 4.0.0-ALPHA through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 645d4fe14a20 (unreleased 11.0) [exec: the claimed JUnit fails 1 of 1 on main with -ea and -da (finder, 10 of 10 with --iters 10; re-run by the cross-model review and the intended-behaviour check; registration check with seeds 5EED1489 and 5EED1490), on 10.6.0 with -ea and -da, and on 10.5.2 and 9.12.3 with -da; the independent reproduction's own test fails on main, 10.6.0, 10.5.2 and 9.12.3 with -ea and -da (on main also re-run by the adjudication with seed 1A2B3C4D); version-port main() probes fail on 4.0.0-ALPHA, 4.0.0, 4.10.4, 5.0.0, 6.6.6, 7.7.3 and 8.11.4 with their no-buffered-document controls passing]; the releases in between by git tag --contains 2a969f828c1 (124 release tags, plus the 8.8.2-8.11.4 releases of the separate lucene-solr branch_8x) and a log of every later commit that touches the slice code (refactors only, each followed by an executed failing release) [read]. Not new in 10.6 (10.5.2 fails the same test). Not fixed upstream: upstream main e1faeba08bde (2026-11-02) changes the fix-site files only by #16867 (2813efa40a3, author date 2026-10-30), which caps the global delete buffer at 1945 MB and stalls indexing during a full flush; it does not touch DeleteSlice, the idle per-thread writer or ramBytesUsed() [read: diff]. Executed on Linux aarch64 with JDK 25 only. The updateDocument trigger (2026-11-02, -da, 1.5M calls, -Xmx256m, default 16 MB buffer): main e1faeba08bd 163 MiB vs control 14 MiB, 10.6.0 157 vs 8, 9.12.3 157 vs 8, 8.11.4 157 vs 7 [exec]
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; also reached by code reading
Upstream and prior reports
none found (2026-11-02, on top of the finder's two scripted prior-report searches (102 queries), the independent reproduction's and the intended-behaviour check's searches (the intended-behaviour check: 168 hits, none on point) and the adjudication's GitHub searches): a scripted prior-report search with 51 queries (16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list, 4 JIRA, 6 web; 220 hits, 0 high, 6 medium); 29 manual GitHub searches on apache/lucene, Elasticsearch, OpenSearch and Solr (DeleteSlice, sliceHead, delete slice, idle DWPT, pure deletes, applyAllDeletes memory, deleteDocuments OutOfMemoryError, DocValuesUpdatesNode, updateNumericDocValue heap and others); the changed files of 30 open PRs that touch IndexWriter or DocumentsWriter* files (none changes DeleteSlice, the idle-DWPT path or the perThread == null branch); JIRA (LUCENE-2956, -3023, -2573, -9304, -3222, -9298, -2324 comments grepped); lists.apache.org dev@ and java-user@; commits by author date since 2026-09-01 on main, branch_11x, branch_10_6 and branch_10x (only #16581, #16867 and the branch_10x decommission). Refresh (2026-11-02): the 13 apache/lucene PRs updated since 2026-11-01 (#16961, #16960, #16959, #16957, #16904, #16896, #16858, #16853, #16805, #16801, #16700, #16564, #16357) are unrelated, no issue was updated since then, 'DeleteSlice' finds only #14261, and the latest commits to the five fix-site files on upstream main e1faeba08bde are #16867 (author 2026-10-30) and older. Nearest items, none the same bug: #14261 (open, 2025-02-19, 0 comments: a nightly OutOfMemoryError in the multi-threaded soft-update stress test testStressUpdateSameDocumentWithMergeOnCommit whose trace allocates in DocumentsWriterPerThread.prepareFlush -> DeleteSlice.apply; undiagnosed, the reporter suspected merging, and that workload has no pure deletes, so it is not shown to be this bug), #3647 / LUCENE-2573 (2011 design comments on global versus per-DWPT delete accounting), #4413 / LUCENE-3340, #2791 / LUCENE-1717, #4295 / LUCENE-3222, #10338 / LUCENE-9298 and #10517 / LUCENE-9478 (fixed precedents at other sites), open #3753 / LUCENE-2679 (2010 feature request about per-session delete RAM), #12572 and #12595 (flushNextBuffer and the global buffer), #16867 (merged 2026-10-30, global buffer cap), dev@ 2013-08-01 'FlushPolicy and maxBufDelTerm' (delete-count trigger applies deletes without writing a segment; no memory discussion). Refresh for the updateDocument trigger (2026-11-02, read-only): 23 queries over apache/lucene issues, PRs and commits, downstream trackers, the mailing lists and the JIRA archive on idle DWPTs, DocumentsWriterDeleteQueue memory, updateDocument OutOfMemoryError and DeleteSlice: 660 hits, none about this trigger (closest: the LUCENE-2956 design, #14261 discussed above, #13571 a sequence-number assertion); #14483 (the AwaitsFix of #14261's test) is about open file handles.
Lucene commit tested
main e1faeba08bde (2026-11-02): the minimal test through gradle and the standalone JUnit runner, assertions on and off, stock and with the recommended fix; its no-document control; 17 IndexWriter suites (356 tests) with the fix on two seeds; the default-config heap probe stock and with the fix. By the hunt on main 645d4fe14a20 and release jars 10.6.0, 10.5.2 and 9.12.3 (the JUnit), 4.0.0-ALPHA, 4.0.0, 4.10.4, 5.0.0, 6.6.6, 7.7.3, 8.11.4, 3.1.0 and 3.6.2 (a main() port), and the introducing commit and its parent (bisection).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:41-45 (at 645d4fe14a20) (class javadoc: 'the garbage collector takes care of pruning the list'; relevant nodes stay referenced by a DWPT's private DeleteSlice or the global slice), :333-371 (DeleteSlice: sliceHead/sliceTail, apply, reset)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:134, (at 645d4fe14a20) :165 (deleteSlice = deleteQueue.newSlice()), :361-389 (finishDocuments advances, applies and resets the slice), :427-439 (prepareFlush applies the slice): the only places that move an idle writer's slice
  • lucene/core/src/java/org/apache/lucene/index/FlushByRamOrCountsPolicy.java:34-50 (at 645d4fe14a20) (onChange: with perThread == null only flushDeletes runs; no DWPT is marked flush-pending)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:153-185 (at 645d4fe14a20) (applyDeleteOrUpdate -> flushControl.doOnDelete(); applyAllDeletes freezes only the global buffer)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:486-503 (at 645d4fe14a20) (doOnDelete passes null; getDeleteBytesUsed javadoc 'heap bytes currently consumed by buffered deletes/updates that would be freed if we pushed all deletes'; ramBytesUsed = getDeleteBytesUsed() + netBytes())
  • lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:157-178 (at 645d4fe14a20) (setRAMBufferSizeMB: 'the writer will flush whenever buffered documents and deletions use this much RAM'; 'just an indicator' explained by concurrent flushes; the 'only approximate' NOTE names delete-by-Query)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:131 (at 645d4fe14a20) (class javadoc 'deleted terms won't trigger a segment flush'), :3753 (flushNextBuffer javadoc points to ramBytesUsed() for 'the IndexWriters current memory consumption')
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThreadPool.java:115-128 (at e1faeba08bde) (getAndLock: freeList.lockAndPoll, else a new writer), :139-153 (marksAsFreeAndUnlock adds the writer back with weight ramBytesUsed()); ApproximatePriorityQueue.java:45-60 (slot = numberOfLeadingZeros(weight): polls by decreasing log of the weight); DocumentsWriterPerThread.java:361-389 (finishDocuments: the only per-document place an in-memory segment's slice moves)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:31-45 (at e1faeba08bde) (class javadoc), :68, :129-139, :198-215, :262-277, :279-293, :333-372, :411-412, :557-560
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:134, (at e1faeba08bde) :165, :361-390 (finishDocuments), :427-439 (prepareFlush)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:380-383 (at e1faeba08bde) (finishDocuments applies the span into pendingUpdates), :718-722 (ramBytesUsed counts pendingUpdates)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:141-165 (at e1faeba08bde) (applyDeleteOrUpdate), :168-186 (applyAllDeletes), :748-750
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:497-501 (at e1faeba08bde) (doOnDelete passes null), :503-516 (getDeleteBytesUsed, ramBytesUsed)
  • lucene/core/src/java/org/apache/lucene/index/FlushByRamOrCountsPolicy.java:34-58 (at e1faeba08bde) (onChange)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:795-799 (at e1faeba08bde) (ramBytesUsed), :1864-1872, :1883, :1962, :1996, :2037, :2070 (entry points)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1886-1890 (at e1faeba08bde) (MatchAllDocsQuery -> deleteAll, LUCENE-6379)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1864-1872 (at e1faeba08bde) (Error -> tragicEvent), :3745-3757 (flushNextBuffer javadoc)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:157-181 (at e1faeba08bde)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:503-510 (at e1faeba08bde)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:122-136 (at e1faeba08bde) (class javadoc, flush paragraph)
  • lucene/CHANGES.txt:15729-15783 (at e1faeba08bde) (LUCENE-2956/2573/2324/2555 entry); :5999 (LUCENE-9298)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterDelete.java:1086, (at e1faeba08bde) :1149, :1200; TestPerSegmentDeletes.java:102-158
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:153-165, (at e1faeba08bde) :246-260 (flushOneDWPT), :175-176 (no push during a full flush), :493-500, :502-580 (doFlush loops over nextPendingFlush); DocumentsWriterFlushControl.java:261-280 (checkout), :762-811, :789-810 (checkoutLargestNonPendingWriter); FlushByRamOrCountsPolicy.java:54-55; IndexWriter.java:6345-6351
History
  • a Lucene main checkout: git log 2813efa40a3 (#16867); diff of FlushByRamOrCountsPolicy.java and DocumentsWriterFlushControl.java against 645d4fe14a20
Diagrams
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:42-45, (at e1faeba08bde) :333-372; DocumentsWriterPerThread.java:361-390

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

Repro files

About the diagrams

Made from the code at e1faeba08bde and the executed runs.

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.