All findings · Lucene finding · LFV-453 · empty-block-update-leaks-flush-ram-accounting

updateDocuments with an empty block leaks about 33 KB of IndexWriter RAM accounting per update-and-refresh round, until the writer flushes after every new document

An updateDocuments or softUpdateDocuments call with an empty list of documents charges about 33 KB to IndexWriter's RAM total, and the next refresh never gives it back. After about 500 such rounds the default 16 MB buffer looks full for good, and the writer flushes after every new document.

Summary

Priority
Low, rank 994Empty-block updateDocuments calls leak RAM accounting until the default writer flushes after every document: a real slowdown with a natural trigger.
Impact
minor
Since
4.5.0 (2013, LUCENE-5006); about 33 KB per round since 9.9.0 (soft path: 7.7.0)introduced 2013-08-19 · over 13 years
Affected
4.5.0-10.6.0 and main, default config; needs an empty block
Status
documented
Found by
non-formal: a small probe while testing softUpdateDocuments edge cases

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

IndexWriter keeps a running total of the RAM its per-thread buffers use and flushes when the total reaches the RAM buffer. An empty-block update still takes a per-thread buffer and stores the delete key in it. The next refresh finds that buffer with 0 documents and drops it without subtracting its bytes.

Background: how IndexWriter counts buffered RAM

  1. New documents and deletes are buffered in per-thread writers (DWPTs); each one becomes a segment when it is flushed.
  2. A flush control adds up the bytes the per-thread writers use. ramBytesUsed() reports that total, and when it reaches the RAM buffer (16 MB by default) the largest writer is flushed.
  3. A refresh (DirectoryReader.open(writer)), commit() or flush() flushes every per-thread writer and subtracts its bytes from the total.
Diagram of the bug: updateDocuments(term, List.of()) takes a per-thread writer and stores the delete key in it, so IndexWriter's RAM total rises by 33,040 bytes; the refresh drops that writer, which holds 0 documents, without subtracting the bytes; after about 500 rounds the total passes the 16 MB RAM buffer and every new document is flushed into its own segment.
How an empty-block update charges about 33 KB to IndexWriter's RAM total, and the refresh that drops the empty per-thread writer never takes it back, until the total passes the 16 MB buffer. 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). Call updateDocuments(new Term("id", "a"), List.of()), meaning "delete key a, add nothing", then refresh with DirectoryReader.open(writer).
  2. You expect the delete to apply and nothing to stay buffered: ramBytesUsed() is 0, as after deleteDocuments(term) or addDocuments(List.of()).
  3. Instead ramBytesUsed() is 33,040. The call took a per-thread buffer and stored the key in it (a 32 KB block); the refresh found that buffer with 0 documents and dropped it without subtracting its bytes.
  4. Every such round adds the same: 20 rounds give 660,800 bytes, 600 rounds give 19,824,000, more than the 16 MB buffer. From then on every added document triggers a flush (single-threaded: 200 small documents, 200 flushes) until the writer is closed.
  5. The delete still applies and no hit is wrong. With assertions on (-ea), close() already fails after one round: netBytes has unexpected value 0 != 33040.

Who is affected

Applications that pass a possibly empty list to updateDocuments(term, docs) or softUpdateDocuments, for example "replace key K's documents with this list". The default config is enough. About 500 such refresh rounds in one writer's life make it flush every document separately until it is closed. No hit is wrong; nothing on disk is malformed.

Mechanism, terms and numbers878 words

IndexWriter.updateDocuments(term, block) and softUpdateDocuments(term, block, softField) accept an empty block: delete or soft-delete by key, add nothing. The delete applies, but the call still takes a per-thread writer (DWPT) and stores the key in that writer's private buffer, so the flush control adds 33,040 bytes (32,960 on the soft path) to its running RAM total. The next full flush (an NRT reopen, commit() or flush()) finds the writer with 0 documents and "drop[s] it on the floor" in DocumentsWriterFlushControl.markForFullFlush without subtracting those bytes; every other exit (flush, abort) subtracts them.

So ramBytesUsed() grows by about 33 KB per such round (an empty-block update, then a full flush) and never comes down for the life of the writer, even past the real heap. Once the phantom total reaches the RAM buffer (16 MB by default, about 500 rounds), the flush policy fires after every added document, so in a single-threaded writer each document becomes its own segment, with the merge and I/O work that follows. No hit is wrong and nothing on disk is malformed; closing the writer clears it. With assertions on, one round makes close() fail and, at the buffer, the writer closes itself. It dates from 4.5.0 (2013); since 9.9.0 (and 7.7.0 on the soft path) each round leaks about 33 KB instead of 165-330 bytes. A four-line fix in the drop branch releases the bytes.

The bug, step by step (main 645d4fe14a20)

  1. The call. IndexWriter.updateDocuments(Term, Iterable) (IndexWriter.java:1593-1610) wraps the term in a delete node and calls DocumentsWriter.updateDocuments; softUpdateDocuments (:1644-1675) does the same with a doc-values update node. It rejects a null term and missing soft-delete fields (:1667-1672), not an empty block.
  2. A writer is taken anyway. DocumentsWriter.updateDocuments (DocumentsWriter.java:412-454) always calls flushControl.obtainAndLock() (:418).
  3. The key lands in the writer's private buffer. In DocumentsWriterPerThread.updateDocuments (DocumentsWriterPerThread.java:245-301) the loop over the block runs zero times, so the writer keeps 0 documents. finishDocuments (:361-390) adds the delete to the global queue and applies it to the writer's own pendingUpdates with docIdUpTo = 0 (:374-377): it can delete nothing here, but the term is stored. The first term allocates the 32 KB first block of BufferedUpdates.DeletedTerms' ByteBlockPool (BufferedUpdates.java:183-184, ByteBlockPool.BYTE_BLOCK_SIZE = 1 << 15) since #12573 (9.9.0); on the soft path FieldUpdatesBuffer accounts a 32 KB block since LUCENE-8590 (7.7.0). Measured: 33,040 bytes (hard) and 32,960 (soft) per round.
  4. The bytes are charged. DocumentsWriterFlushControl.doAfterDocument (DocumentsWriterFlushControl.java:207-250) skips deltas below 16 KB (:211-215); 33,040 bytes is above that, so it commits them to the writer (:225) and adds them to activeBytes (:236). The writer goes back to the pool with 0 documents.
  5. The full flush forgets them. A refresh, commit or flush calls markForFullFlush (:557). A writer with documents is marked pending, which moves its bytes to flushBytes (:365-376), and doAfterFlush subtracts them once the segment is written (:290-295). A writer with 0 documents takes the branch at :596-602, "In this case we simply remove it from the pool and drop it on the floor": perThreadPool.checkout(next) and nothing else. doOnAbort (:378-389) shows the intended bookkeeping: it subtracts getLastCommittedBytesUsed().
  6. Nobody can take them back. The dropped writer is out of the pool, so no later flush, abort or deleteAll() sees it (executed in the fact check: 33,040 bytes remain after a further commit() and after deleteAll()). No operation on the still-open writer repairs the total; rollback() closes the writer and, under -ea, hits the same netBytes assertion (by reading). ramBytesUsed() (IndexWriter.java:796-799 -> DocumentsWriterFlushControl.java:501-504) keeps the bytes until the writer is closed.
  7. The flush storm. FlushByRamOrCountsPolicy.onChange (FlushByRamOrCountsPolicy.java:34-53) flushes the largest writer when activeRam + deletesRam >= limit (:49-50). Once the phantom bytes alone reach the limit, that holds after every added document, so every document triggers a flush of the largest non-pending writer that holds documents (DocumentsWriterFlushControl.java:749-772). In a single-threaded workload that is the writer holding just the new document: one segment per document. With several indexing threads it can be another, larger writer (by reading).
  8. With -ea. The next empty-block update after that point reaches assert perThread.getNumDocsInRAM() > 0 (FlushPolicy.java:74); IndexWriter treats the AssertionError as tragic and closes itself. The message users see, netBytes has unexpected value 0 != N, is the rollback's own check (DocumentsWriter.java:232-233), which also fails close() after a single round.

Precondition, exactly: the per-thread writer that took the empty update still has 0 documents at the next full flush. In a single-threaded workload a document added before the refresh lands in the same writer and nothing leaks; with concurrent indexing it can leak while other writers hold documents. Several empty updates between two full flushes reuse one writer, so the leak counts full flushes, not calls. Other entry points (executed in the fact check): a zero-row column batch, updateDocuments(Term, ColumnBatch), leaks the same 33,040 bytes through DocumentsWriter.updateBatch. updateDocuments(Query, List.of()) takes the same path, but a delete-by-Query is accounted at about 76 bytes, below the 16 KB granularity skip, so with the default config it leaves 0 bytes (76 per round with setMaxBufferedDocs).

Exposure and likelihood412 words
  • Who is hit. Code that calls updateDocuments(term, block) or softUpdateDocuments(term, block, ...) with a block that can be empty. The default IndexWriterConfig, codec, flush policy and merge policy suffice. The block APIs are @lucene.experimental; softUpdateDocuments is documented as "Expert".
  • Scale. About 33 KB per round (an empty-block update, then a refresh or commit) since 9.9.0 (updateDocuments) and 7.7.0 (softUpdateDocuments): 16,777,216 / 33,040 = 508 rounds fill the default buffer (the -ea failure came at rounds 506-509). Older releases leak 165 or 330 bytes per round: on 4.5.0, 110,000 rounds left 18,150,000 bytes and 200 documents then became 200 segments.
  • Assertions off (the JVM default). ramBytesUsed() over-reports without bound and past the real heap (with a 2 GB buffer: 268,450,000 bytes on a 268,435,456-byte max heap after 8,125 rounds); the bytes are phantom, no heap is held. Past the buffer every added document triggers a flush; in these single-threaded runs, after 600 rounds, 200 small documents caused 200 flushes with the stock merge policy, and with NoMergePolicy 100 documents became 100 segments. Merge and I/O load follow; throughput was not measured. Code that drives its own flushing from ramBytesUsed(), as the flushNextBuffer() javadoc invites (IndexWriter.java:3752-3753), is misled the same way; getFlushingBytes() is not inflated (by reading).
  • Not affected. Deletes and soft deletes still apply, no hit is wrong, segments are written by the normal flush path (by reading; CheckIndex not run), and no hang was found (by reading the stall condition). Closing the writer and opening a new one clears it. By reading (not executed), back-pressure does change: stall control holds indexing threads only while activeBytes is below twice the RAM buffer (DocumentsWriterFlushControl.java:318), so once the phantom bytes alone reach that (about 1,016 rounds at 16 MB), slow flushes can no longer stall indexing for the rest of the writer's life.
  • Assertions on (tests, development). One empty-block update plus a refresh or commit makes close() throw; at the buffer the writer closes itself tragically and loses what was buffered. An application's own tests under Lucene's test framework would see the first symptom.
  • Not hit. In their default indexing paths (read from their source, not run; not a claim about every extension or branch), Elasticsearch and OpenSearch call softUpdateDocuments only for more than one document, and Solr always passes at least the root document. In Lucene no non-test code calls these methods except IndexWriter, and no upstream test passes a literal empty block.

Minimal repro

One JUnit test on the public API: a default-config writer, one updateDocuments(term, List.of()), one NRT reopen, then ramBytesUsed() must be 0. Deterministic, single thread, under a second.

TestEmptyBlockUpdateRamAccounting.java (one test, public API)35 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.index;

import java.util.List;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestEmptyBlockUpdateRamAccounting extends LuceneTestCase {

  public void testEmptyBlockUpdateReleasesRamOnRefresh() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      w.updateDocuments(new Term("id", "a"), List.of());
      DirectoryReader.open(w).close();
      // expected 0 buffered bytes after the refresh (as after deleteDocuments); actual ~33 KB stays
      assertEquals(0L, w.ramBytesUsed());
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestEmptyBlockUpdateRamAccounting.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestEmptyBlockUpdateRamAccounting -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure, close() then succeeds)
Expected
ramBytesUsed() is 0 after the refresh, as after deleteDocuments(term).
Observed
expected:<0> but was:<33040>; with -ea, close() also throws netBytes has unexpected value 0 != 33040.

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

More files, environment, full output240 words

Fully default IndexWriterConfig, ByteBuffersDirectory; the test itself uses no randomness (the test framework still randomizes the default codec and similarity; the result is the same at seeds DEADBEEF and 5EED1234). It asserts only the accounting: after a refresh nothing is buffered, so ramBytesUsed() must be 0, as it is after deleteDocuments(term) and with the fix. The growth over rounds and the flush storm are consequences of the same leak and are not re-shown; the delete still applying, the soft path and the segment counts rest on the other checkers' runs, not on this test. It sits in org.apache.lucene.index only because that is where an upstream test would go. As a regression test it is the same test (for example in TestFlushByRamOrCountsPolicy or TestIndexWriter); it fails on stock and passes with the fix. All runs were on Linux aarch64 with OpenJDK 25.0.3; the missing subtraction is platform-independent, the exact byte count was measured only there.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on 10.6.0
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# gradle, stock 645d4fe14a20, seed DEADBEEF, assertions on
TestEmptyBlockUpdateRamAccounting > testEmptyBlockUpdateReleasesRamOnRefresh FAILED
    java.lang.AssertionError: expected:<0> but was:<33040>
    Suppressed: java.lang.AssertionError: netBytes has unexpected value 0 != 33040   (from close())

# assertions off (-Ptests.asserts=false): the same message, close() succeeds
# 10.6.0 release jars, assertions on and off: the same message (suppressed close() error with -ea only)

# with the fix: 1 test, 0 failures, assertions on and off

Why it looks unintended

  • Nothing forbids an empty block: the javadocs do not require a non-empty one, and addDocuments(List.of()) on the same path leaves 0 bytes.
  • setRAMBufferSizeMB: the writer flushes "whenever buffered documents and deletions use this much RAM"; here nothing is buffered.
  • Lucene's own checks expect 0: close() asserts it, and TestFlushByRamOrCountsPolicy asserts activeBytes() == 0 after close.
The full argument190 words
  • Accepted input. The javadocs of updateDocuments (IndexWriter.java:1593-1604) and softUpdateDocuments (:1644-1663) do not require a non-empty block; softUpdateDocuments validates its other arguments (:1667-1672) but not this one. addDocuments(List.of()) takes the same path with no delete and leaves 0 bytes (executed controls).
  • The RAM buffer's contract. LiveIndexWriterConfig.setRAMBufferSizeMB: "When this is set, the writer will flush whenever buffered documents and deletions use this much RAM" (LiveIndexWriterConfig.java:162-163).
  • Lucene's invariants. DocumentsWriter.abort asserts flushControl.netBytes() == 0 (DocumentsWriter.java:232-233); TestFlushByRamOrCountsPolicy asserts flushControl.activeBytes() == 0 after writer.close() (TestFlushByRamOrCountsPolicy.java:102-103); setFlushPending, doAfterFlush and doOnAbort all subtract a writer's committed bytes. The drop branch is the only exit that does not.
  • The documented slack is bounded and in the other direction. Deletes by Query are under-counted (LiveIndexWriterConfig.java:174-177), and the 16 KB granularity skip is bounded by design: "we'd never be missing more than 64*16kB = 1MB" (DocumentsWriterFlushControl.java:201-202). This is an unbounded, permanent over-count.
  • Precedent. #5464 (LUCENE-4398, 2012): a different accounting leak with the same symptom, "every single document being flushed into its own segment", was treated as a bug and fixed with a test.
The devil's-advocate check239 words

The strongest case for "intended": the block APIs are experimental and softUpdateDocuments is Expert, a pure delete should use deleteDocuments or updateDocValues, the RAM buffer is "just an indicator when to flush" (LiveIndexWriterConfig.java:167-172), the drop branch's comment says a 0-document writer only comes from a concurrent flush, and internal code treats 0-document writers as non-flushable (the setFlushPending javadoc: "must have indexed at least on Document" [sic], DocumentsWriterFlushControl.java:360-364; FlushPolicy.java:74 and DocumentsWriterPerThread.prepareFlush, :428, assert it).

Why it fails: nothing forbids or rejects the empty block, and the experimental tag is about API stability, not about the writer's accounting drifting; the "indicator" sentence says the writer may use more memory than the buffer, not that the total may grow forever, and here it passes the real heap; the comment shows the empty block was not foreseen, it is not a user-facing precondition. The internal assertions are assumptions, not a documented restriction on callers; they are a fair argument for rejecting empty blocks or skipping the writer (the other two options under Suggested fix) rather than for the leak. In 2020 mikemccand asked on apache/lucene-solr#1397, the PR that wrote today's branch, "Hmm why would we ever have nextRam > 0 but next.getNumDocsInRAM() == [0]?" (the [0] is ours; the comment ends at ==), and nobody replied; an empty-block update is one concrete way to reach that state. Every checker rated the case weak: real, minor.

Suggested fix

Recommended Release the dropped writer's bytes in markForFullFlush

In markForFullFlush, when a per-thread writer with 0 documents is dropped, subtract its committed bytes from activeBytes (four added lines). Validated: the repro and the finder's 4 tests pass with assertions on and off, 233 tests of the 11 closest suites pass, and the format and lint checks pass.

         } else {
           // it's possible that we get a DWPT with 0 docs if we flush concurrently to
-          // threads getting DWPTs from the pool. In this case we simply remove it from
-          // the pool and drop it on the floor.
+          // threads getting DWPTs from the pool, or after an update with an empty block of
+          // documents. In this case we simply remove it from the pool and drop it on the floor,
+          // releasing whatever RAM it already accounted for.
           boolean checkout = perThreadPool.checkout(next);
           assert checkout;
+          synchronized (this) {
+            activeBytes -= next.getLastCommittedBytesUsed();
+            assert assertMemory();
+          }
         }
All options and trade-offs3 options · 591 words

The hunt's fix, unchanged (an independent checker wrote the same change), run through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF). It applies to upstream main after #16867 with a 12-line offset. The other two options are alternatives maintainers may prefer; neither was implemented.

Release the dropped writer's bytes in markForFullFlush (validated by the checks) (recommended)

In the 0-document branch of DocumentsWriterFlushControl.markForFullFlush (:596-602), subtract next.getLastCommittedBytesUsed() from activeBytes under the flush-control lock, then check assertMemory(). A 0-document writer is never flush-pending (setFlushPending acts only when getNumDocsInRAM() > 0, :367), so its bytes are in activeBytes, and the writer is locked here. The delete itself is unaffected: it is in the global delete queue, and the private copy could only apply to this writer's documents, of which there are none. Because it fixes the drop site, it covers (by reading) any 0-document writer that holds committed bytes, including the zero-row column-batch route (which leaks the same 33,040 bytes, executed in the fact check) and, with setMaxBufferedDocs, the Query routes.

         } else {
           // it's possible that we get a DWPT with 0 docs if we flush concurrently to
-          // threads getting DWPTs from the pool. In this case we simply remove it from
-          // the pool and drop it on the floor.
+          // threads getting DWPTs from the pool, or after an update with an empty block of
+          // documents. In this case we simply remove it from the pool and drop it on the floor,
+          // releasing whatever RAM it already accounted for.
           boolean checkout = perThreadPool.checkout(next);
           assert checkout;
+          synchronized (this) {
+            activeBytes -= next.getLastCommittedBytesUsed();
+            assert assertMemory();
+          }
         }

Trade-offs

No correctness cost known. One more short lock section per dropped writer during a full flush, which is rare; not benchmarked. The full ./gradlew check was not run.

What the checks validated

  • The minimal repro fails on stock and passes with the fix, assertions on and off.
  • The finder's 4-test class (both entry points over 100 commit rounds at a 1 MB buffer, 200 documents in at most 3 segments, close() after one round, the addDocuments(List.of()) control): 4 of 4 pass, assertions on and off.
  • The 11 closest suites (TestIndexWriter, TestIndexWriterDelete, TestFlushByRamOrCountsPolicy, TestDocumentsWriterDeleteQueue, TestDocumentsWriterStallControl, TestDocumentsWriterPerThreadPool, TestIndexWriterExceptions, TestIndexWriterThreadsToSegments, TestSoftDeletesRetentionMergePolicy, TestPendingSoftDeletes, TestSoftDeletesDirectoryReaderWrapper): 233 tests, 0 failures, 11 skipped.
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest of :lucene:core pass (the separate editorconfig linter, eclint, is not configured there and did not run).
  • Earlier: a fix of the same shape on 4.5.0 removes the leak there (0 bytes after 110,000 rounds, 200 documents in 1 segment).

Do not take a per-thread writer for an empty block

Put the delete or doc-values update only into the global delete queue, as deleteDocuments and updateDocValues do.

Trade-offs

Avoids the 32 KB allocation altogether. But the block is an Iterable: detecting "empty" means taking its iterator before obtaining a writer and handing that same iterator on, plus a second sequence-number path, and the column-batch route needs the same. It leaves the drop branch unguarded for any other source of a 0-document writer with bytes. Not implemented or tested.

Reject an empty block with IllegalArgumentException

One check in each API, which also documents the restriction.

Trade-offs

Changes accepted behaviour: addDocuments(List.of()) is accepted today, and callers that pass an empty block to mean "delete only" would start failing. Leaves the drop branch unguarded. Not implemented or tested.

Downstream note

A product that forwards a client-supplied, possibly empty list of documents into these calls should map an empty list to deleteDocuments (or updateDocValues for a soft delete) until this is fixed; the default indexing paths of Elasticsearch, OpenSearch and Solr do not pass an empty block (read from their source, not run).

How it was found

Non-formal. While extending an executable reference model of soft deletes to softUpdateDocuments edge cases, an AI tool wrote a small probe: an empty-block softUpdateDocuments followed by close() failed under -ea with netBytes has unexpected value 0 != 32960. Further probes measured the RAM growth and the one-segment-per-document flushing with assertions off.

The method in full135 words

Method: non-formal (probes and executed tests; no model checker, solver or proof produced the signal), a hunt unit on soft deletes.

  • The finder was extending a model-based test of soft deletes to softUpdateDocuments edge cases. A probe (softUpdateDocuments(term, List.of(), soft), then close()) failed under -ea in DocumentsWriter.abort.
  • Two more probes then measured, with assertions off, 660,800,000 bytes after 20,000 rounds on a 64 MB heap, and, with a 1 MB RAM buffer and NoMergePolicy, 201 segments for 200 small documents after 100 rounds (2 in the controls).
  • A JUnit test on the stock jars, with assertions on and off, became the candidate; an independent reproducer, a devil's advocate, read-only reviews by another model family and an adjudication then checked it, and the minimal test was cut from the adjudication's test.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues and PRs, open PRs' diffs, the JIRA archive, the mailing lists or downstream projects (184 logged queries, 2026-10-26; GitHub re-checked 2026-10-31). Closest: an unanswered 2020 review question by mikemccand about the same 0-document state, which reports no leak or trigger.

Searches4 searches

Closest items: the apache/lucene-solr#1397 review question (LUCENE-9304, 2020), "Hmm why would we ever have nextRam > 0 but next.getNumDocsInRAM() == [0]?" (the [0] is ours), unanswered; #5464 (LUCENE-4398, fixed 2012), the same one-segment-per-document symptom from a different leak; #10080 (LUCENE-9037, Won't Fix), RAM growth from failing documents; #12573 and its follow-ups, the pool change, which never mention 0-document writers. #16867 (merged 2026-10-30) caps the global delete buffer and does not touch the drop branch. Gaps: GitHub search does not index inline review comments; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr)2026-10-26the hunt's search tool in 2 runs plus 68 GraphQL searches: markForFullFlush, "drop it on the floor", netBytes, getLastCommittedBytesUsed, empty block updateDocuments / softUpdateDocuments, ramBytesUsed growth, one segment per documentno report; the unanswered #1397 review question; #5464, #10080, #12573 (different bugs)
Open PRs2026-10-26changed files of all 408 open PRs; the 5 closest diffs of the 24 that touch an affected file readnone changes the 0-document branch
JIRA archive, mailing lists, downstream, web2026-10-2612 extra JIRA queries (LUCENE, SOLR); dev@, java-user@, issues@, commits@; Elasticsearch, OpenSearch, Solr; CHANGES.txt and git log -S over the full historyno report
GitHub re-check2026-10-3114 terms as issues and as PRs ("netBytes has unexpected value", markForFullFlush, "drop it on the floor", "updateDocuments empty", "ramBytesUsed leak", "one segment per document", DocumentsWriterFlushControl, ...); the 96 PRs updated since 2026-10-26no report; #16867 (merged 2026-10-30) touches the flush control but not the drop branch

History

Introduced in 4.5.0 (2013) by LUCENE-5006 (75ab79f0aa35, s1monw). About 33 KB per round since 9.9.0 (#12573) on updateDocuments and since 7.7.0 (LUCENE-8590) on softUpdateDocuments; 165-330 bytes before. Fails on 10.6.0 and main; not fixed upstream.

Versions tested8 rows

Introduced by LUCENE-5006 (#6070), commit "Simplified DW and DWPT synchronization and concurrent interaction with IW" (s1monw, 2013-08-19; trunk 75ab79f0aa35, branch_4x 8003cc782982; pre-GitHub, no PR), first released in 4.5.0. It created the delete slice in the per-thread writer's constructor, so an empty block stopped throwing a NullPointerException, and replaced the in-place re-initialization of a 0-document writer with a reset that zeroes its byte count without subtracting it.

Kept since: LUCENE-6676 (5.3.0) changed the reset call; LUCENE-9304 (apache/lucene-solr#1397; 8.6.0, main 2602269f3ec8) rewrote it into today's "drop it on the floor" branch and added the close-time netBytes assertion. #12198 (9.6.0) hides the 165-byte leak under the default config; #12573 (9.9.0 d3a3391d225, main 8b84f6c0965) and LUCENE-8590 (7.7.0) made each round leave about 33 KB.

Not fixed upstream (2026-10-31): upstream main 2f424fd428d, branch_11x, branch_10_6 and the 10.6.0 tag have the same branch.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails through gradle, assertions on and off (33,040 bytes)
10.6.0 affectedthe minimal test fails on the release jars, assertions on and off
9.9.0-10.5.2 affected33,040-33,104 bytes per round; one segment per document after about 500 rounds (executed on 9.9.0, 9.12.3, 10.0.0, 10.5.2)
9.6.0-9.8.x hidden by defaultupdateDocuments: 0 bytes with the default config, 165 with setMaxBufferedDocs; softUpdateDocuments: about 33 KB per round
7.7.0-9.5.x affectedsoftUpdateDocuments: about 33 KB per round; updateDocuments: 165 bytes
4.5.0-7.6.x small leak165 bytes per round (updateDocuments), 330 (softUpdateDocuments, from 7.4.0); on 4.5.0, 110,000 rounds left 18,150,000 bytes and 200 documents became 200 segments
4.0.0, 4.4.0 fails loudlythe same call throws a NullPointerException
fix (main; same shape on 4.5.0) not affected0 bytes; documents flush normally

Independent checks

Confirmed with high confidence by the adjudication after an independent reproducer, an intended-behaviour devil's advocate and read-only reviews by another model family (OpenAI GPT-5.6) that agreed on the root cause; re-run on 10.6.0 and main, and the fix validated through gradle.

Checks and verdicts392 words

Each checker that executed wrote its own test from the claim; the adjudication re-ran the independent reproducer's tests, its own minimal test and the fix. The cross-model reviews could not run code, so their verdicts are procedural.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateIts JUnit fails 3 of 4 with -ea and 2 of 4 with -da on main, 10.5.2 and 9.12.3; the fix passes 4 of 4 and 233 upstream tests.
root-cause review (OpenAI GPT-5.6) uncertain (medium), proceduralRead-only: agreed with the root cause and the fix, found the inputs in contract and the intended argument weak; named the Query and column-batch routes.
Independent reproducer realOwn 9-test class: 6 fail with -da, 8 with -ea; 100 documents became 100 segments at 16 MB; past the max heap with a 2 GB buffer; traced the -ea failure to FlushPolicy.java:74; its own fix passes 9 of 9.
Refute review (OpenAI GPT-5.6) not real (high), procedural onlyRead-only, so its verdict is procedural; on substance it agreed with the root cause. A separate stock-config test reproduced it (19,824,000 bytes, 200 flushes) and passed with the fix.
Devil's advocate real, minorOnly internal assertions and a comment assume a 0-document writer holds no bytes; stock-config probe: 112 segment names for 100 documents, also with concurrent indexing.
adjudication confirmed (high), minorOwn default-config test: 660,800 bytes after 20 rounds (= 20 x 33,040), delete control 0; the fix passes.
history since 4.5.0, correctedProbe on 23 releases plus main; corrected the candidate's "no leak before 9.9".
audits keep; minimal test verifiedCredibility audit: 600 rounds then 100 documents give 100 segments. Noise audit: the one-test repro fails with -ea and -da and passes with the fix.
this doc real, fix validated10.6.0 jars fail; through gradle stock fails, the fix passes the repro, the finder's class and 233 closest-suite tests; lint passes.
fact checks (2026-11-01) real; wording fixedAn independent checker re-ran the minimal test on main and the 10.6.0 jars and the fix, and its own probe executed the column-batch, Query and deleteAll() routes; a read-only cross-model check (OpenAI GPT-5.6) asked for the single-thread qualifier and the stall-control and internal-assertion caveats. All corrections are in this doc; one finding was rejected with a reason.

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

Finding record

LFV-453, snapshot of 2026-11-06

Registry title
IndexWriter.updateDocuments / softUpdateDocuments with an empty block leave about 33 KB of phantom RAM accounting at every refresh or commit that drops the empty per-thread writer, so ramBytesUsed grows without bound and, once it passes the RAM buffer, every later document is flushed into its own segment
Area
index / IndexWriter flush-control RAM accounting (DocumentsWriterFlushControl.markForFullFlush drops a 0-document DocumentsWriterPerThread; reached through updateDocuments and softUpdateDocuments with an empty block)
Priority
Low, rank 994: Empty-block updateDocuments calls leak RAM accounting until the default writer flushes after every document: a real slowdown with a natural trigger.
Impact class
minor
Impact note
Applications that call updateDocuments(term, block) or softUpdateDocuments(term, block, softField) with an empty block (delete or soft-delete by key, add nothing), with the default IndexWriterConfig: every refresh, commit or flush that finds the per-thread writer that took such a call still holding 0 documents leaves its delete or doc-values buffer (about 33 KB on the updateDocuments path since 9.9.0 and on the softUpdateDocuments path since 7.7.0; 165-330 B on older paths) counted in ramBytesUsed for the rest of the writer's life, and after about 510 such rounds at the default 16 MB buffer the writer flushes after every added document (200 tiny documents -> 200 flushes), with the merge and I/O load that follows; deletes still apply, no hit is wrong, nothing on disk is malformed, closing the writer and opening a new one clears it, and with -ea the writer closes itself with a tragic AssertionError.
Summary
You call IndexWriter.updateDocuments(term, List.of()), or softUpdateDocuments(term, List.of(), softField), to delete a key's documents and add nothing, then refresh or commit; you expect the delete to apply and nothing to stay buffered, so ramBytesUsed() returns to 0 as it does after deleteDocuments(term) or addDocuments(List.of()). Instead the call took a per-thread writer and charged its delete buffer (about 33 KB) to the flush control, and the next full flush throws that 0-document writer away without taking the charge back, so ramBytesUsed() climbs by about 33 KB per round and never comes down (20 rounds with a default config: 660,800 B). Once the phantom total passes the RAM buffer (about 510 rounds at the default 16 MB) the writer flushes after every document, so 200 small documents become 200 segments; with assertions enabled the writer closes itself with an AssertionError instead.
Introduced
75ab79f0aa3509192451c8acb77a4f4f1011fa2e (trunk, svn r1515459) and its branch_4x backport 8003cc7829823ba48bf67686c999b7fb7e4fd42f (svn r1515463), LUCENE-5006 / GitHub issue #6070 'Simplified DW and DWPT synchronization and concurrent interaction with IW' (s1monw); pre-GitHub, no PR. The change created the delete slice in the DWPT constructor, so an empty block no longer throws a NullPointerException, and replaced the in-place re-initialization of a 0-document writer with ThreadState.reset(), which zeroes its byte count without subtracting it from activeBytes [read: history clone; exec: 4.4.0 throws NullPointerException, 4.5.0 leaks, 4.5.0 with a shadow fix is clean]. Later commits kept the drop and changed only the size or the symptom: LUCENE-6676 (5.3.0, 7b0b145e173), LUCENE-8200 (#9247, 7.4.0, b627fae0321, added softUpdateDocuments), LUCENE-8590 (#9636, 7.7.0, eaf840867fa, 32 KB FieldUpdatesBuffer block), LUCENE-9304 (apache/lucene-solr#1397; 8.6.0 83b34d47900, main 2602269f3ec8: today's 'drop it on the floor' branch in markForFullFlush and the close-time netBytes assertion), #12198 (9.6.0, 4aab7c4bc2d: the ramBufferGranularity skip hides the 165 B leak under the default config) and #12573 (9.9.0, d3a3391d225 / main 8b84f6c0965: DeletedTerms ByteBlockPool, 32 KB first block). (2013-08-19 (LUCENE-5006 trunk commit and branch_4x backport); first released in 4.5.0 (tag dated 2013-10-02))
First affected version
4.5.0 [exec] (4.0.0 and 4.4.0 throw a NullPointerException on the same call instead [exec]); the flush storm at a few hundred rounds under the default config starts with 7.7.0 on the softUpdateDocuments path and 9.9.0 on the updateDocuments path [exec]
Affected versions
every release from 4.5.0 to 10.6.0 and main ea57360912ed: a version-portable public-API probe leaks on 4.5.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.4.0, 7.6.0, 7.7.0, 7.7.3, 8.5.2, 8.6.0, 8.11.4, 9.0.0, 9.5.0, 9.6.0 and 9.8.0 (with setMaxBufferedDocs or the soft path), 9.9.0, 9.12.3, 10.0.0, 10.5.2 and main [exec]; releases in between are inferred from the unchanged drop branch, whose two rewrites (LUCENE-6676, LUCENE-9304) are bracketed by executed releases [read]. Bytes left per round (one empty-block update, then commit, nothing buffered): updateDocuments(term, []) 165 B on 4.5.0-9.5.x, 0 under the default config on 9.6.0-9.8.x (hidden by the #12198 granularity skip; 165 B with setMaxBufferedDocs), 33,040-33,104 B from 9.9.0; softUpdateDocuments(term, [], f) 330 B on 7.4.0-7.6.x and 32,952-32,960 B from 7.7.0 (the API does not exist before 7.4.0). Under -ea: the FlushPolicy 'getNumDocsInRAM() > 0' assertion closes the writer tragically once the phantom bytes reach the buffer (rounds 506-509 at 16 MB on 7.7.0+ soft and 9.9.0+ hard); close() also asserts netBytes == 0 from 8.6.0. The finder's JUnit fails on 9.9.0, 9.12.3, 10.5.2 and main (-ea and -da) and only on its soft-path test on 9.8.0 [exec]. Not affected: 4.0.0-4.4.x (NullPointerException instead). Not fixed upstream: the 0-document branch is unchanged on main 49483e82b357, branch_10x and branch_10_6 as of 2026-10-26 [read-only check], and 10.6.0 (released 2026-10-29) has it too [exec 2026-10-31: the minimal test fails on the 10.6.0 release jars (SHA-1 checked against Maven Central) with -ea and -da, 33,040 bytes].
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its “not real” is procedural), 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
none found (2026-10-26, read-only; 184 logged queries and checks, namely the scripted prior-report search in 2 runs (91 queries over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web), 68 GitHub GraphQL searches on apache/lucene, downstream projects and globally, 12 extra JIRA queries over LUCENE and SOLR, CHANGES.txt and git log -S over the full history, and the changed files of all 408 open apache/lucene PRs (24 touch the affected files, the 5 closest diffs read); the finder's earlier 33-query run and the intended-behaviour check's 27 GraphQL searches also found nothing); not fixed upstream (the 0-document drop branch is unchanged on main 49483e82b357, branch_10x and branch_10_6; no open PR changes it; #16867, open then and merged 2026-10-30, touches DocumentsWriterFlushControl but not markForFullFlush). Closest items, none reporting this bug: an unanswered 2020 review question by mikemccand on apache/lucene-solr#1397 (LUCENE-9304, the PR that wrote today's drop branch), 'why would we ever have nextRam > 0 but next.getNumDocsInRAM() == [0]?' (the [0] is ours; the comment ends at ==), which asks about the same state but reports no leak, trigger or symptom; #5464 / LUCENE-4398 (fixed 2012: a different accounting leak in TermsHashPerField with the same one-segment-per-document symptom, treated as a bug and fixed with a test); #10080 / LUCENE-9037 (Won't Fix 2020: RAM of a DWPT growing through repeated failing documents; different trigger, symptom and fix); #12573, #12630 and #12586 (the DeletedTerms pool change and its follow-ups; none mentions 0-document writers). Gaps: GitHub search does not index inline review comments (only the named PRs' threads were read); private reports to security@lucene.apache.org are not visible.
Lucene commit tested
main 645d4fe14a20 through gradle in a scratch worktree: the minimal test on stock and with the fix, assertions on and off; with the fix also the finder's 4-test class, the 11 closest IndexWriter, flush-control and soft-delete suites (233 tests) and the format, ecj and forbidden-APIs checks. The minimal test on the 10.6.0 release jars (assertions on and off). The hunt's tests on main ea57360912ed (same affected files), 10.5.2, 9.12.3 and 9.8.0, and a history probe on 23 releases from 4.0.0 to 10.5.2. All runs on Linux aarch64 with OpenJDK 25.0.3. Upstream main 2f424fd428d (2026-10-30), branch_11x, branch_10_6 and the 10.6.0 tag have the same drop branch.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:207-249 (doAfterDocument commits the DWPT's byte delta to activeBytes; granularity skip at :211-212), :378-385 (doOnAbort subtracts getLastCommittedBytesUsed), :557 (markForFullFlush), :596-601 (0-document branch: checkout, 'drop it on the floor', no subtraction)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:412-452 (updateDocuments takes a DWPT via obtainAndLock at :418 and calls doAfterDocument at :434 even for an empty block), :228-233 (abort asserts netBytes() == 0)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:370-378 (finishDocuments applies the delete node to pendingUpdates with 0 documents); BufferedUpdates.java:180-185 (DeletedTerms ByteBlockPool with DirectTrackingAllocator); FieldUpdatesBuffer.java:71-90 (constructor accounts its BytesRefArray buffers)
  • lucene/core/src/java/org/apache/lucene/index/FlushPolicy.java:74 (assert perThread.getNumDocsInRAM() > 0); IndexWriter.java:1592-1610 (updateDocuments javadoc 'Atomically deletes documents matching the provided delTerm and adds a block of documents', @lucene.experimental), :1645-1664 (softUpdateDocuments, Expert); LiveIndexWriterConfig.java:162-163 ('the writer will flush whenever buffered documents and deletions use this much RAM') and :169 ('this limit is just an indicator when to flush')
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:796-799, 1523-1526, 1593-1675
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:232-233, 412-454, 456-491, 667-680
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:245-301, 361-390
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/BufferedUpdates.java:176-221; lucene/core/src/java/org/apache/lucene/util/ByteBlockPool.java:43-46
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:102-104, 194-250, 290-295, 365-389, 501-504, 557-602
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/FlushPolicy.java:72-79; FlushByRamOrCountsPolicy.java:34-53
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1603, 1645, 1662 (experimental, Expert)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1523-1526, 1593-1675
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:157-190
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:232-233; DocumentsWriterFlushControl.java:201-202, 290-295, 365-389, 596-602
  • a Lucene main checkout lucene/core/src/test/org/apache/lucene/index/TestFlushByRamOrCountsPolicy.java:102-103
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:365-376 (setFlushPending), 596-602; DocumentsWriterPerThread.java:374-377
History
  • a Lucene clone origin/main 2f424fd428d, origin/branch_11x 603af870375, origin/branch_10_6 7e5aec4370e, releases/lucene/10.6.0: DocumentsWriterFlushControl.java (0-document branch)

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

Repro files

About the diagrams

Made in parallel with this doc, from the code at 645d4fe14a20 and the executed 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.