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.
- New documents and deletes are buffered in per-thread writers (DWPTs); each one becomes a segment when it is flushed.
- 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. - A refresh (
DirectoryReader.open(writer)),commit()orflush()flushes every per-thread writer and subtracts its bytes from the total.
- Open an
IndexWriterwith the default config (16 MB RAM buffer). CallupdateDocuments(new Term("id", "a"), List.of()), meaning "delete key a, add nothing", then refresh withDirectoryReader.open(writer). - You expect the delete to apply and nothing to stay buffered:
ramBytesUsed()is 0, as afterdeleteDocuments(term)oraddDocuments(List.of()). - 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. - 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.
- 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.
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 call.
IndexWriter.updateDocuments(Term, Iterable)(IndexWriter.java:1593-1610) wraps the term in a delete node and callsDocumentsWriter.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. - A writer is taken anyway.
DocumentsWriter.updateDocuments(DocumentsWriter.java:412-454) always callsflushControl.obtainAndLock()(:418). - 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 ownpendingUpdateswithdocIdUpTo= 0 (:374-377): it can delete nothing here, but the term is stored. The first term allocates the 32 KB first block ofBufferedUpdates.DeletedTerms'ByteBlockPool(BufferedUpdates.java:183-184,ByteBlockPool.BYTE_BLOCK_SIZE= 1 << 15) since #12573 (9.9.0); on the soft pathFieldUpdatesBufferaccounts a 32 KB block since LUCENE-8590 (7.7.0). Measured: 33,040 bytes (hard) and 32,960 (soft) per round. - 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 toactiveBytes(:236). The writer goes back to the pool with 0 documents. - The full flush forgets them. A refresh, commit or flush calls
markForFullFlush(:557). A writer with documents is marked pending, which moves its bytes toflushBytes(:365-376), anddoAfterFlushsubtracts 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 subtractsgetLastCommittedBytesUsed(). - 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 furthercommit()and afterdeleteAll()). No operation on the still-open writer repairs the total;rollback()closes the writer and, under-ea, hits the samenetBytesassertion (by reading).ramBytesUsed()(IndexWriter.java:796-799->DocumentsWriterFlushControl.java:501-504) keeps the bytes until the writer is closed. - The flush storm.
FlushByRamOrCountsPolicy.onChange(FlushByRamOrCountsPolicy.java:34-53) flushes the largest writer whenactiveRam + 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). - With
-ea. The next empty-block update after that point reachesassert 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 failsclose()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)orsoftUpdateDocuments(term, block, ...)with a block that can be empty. The defaultIndexWriterConfig, codec, flush policy and merge policy suffice. The block APIs are@lucene.experimental;softUpdateDocumentsis 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-eafailure 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 withNoMergePolicy100 documents became 100 segments. Merge and I/O load follow; throughput was not measured. Code that drives its own flushing fromramBytesUsed(), as theflushNextBuffer()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
activeBytesis 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
softUpdateDocumentsonly 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.
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.
/*
* 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());
}
}
}
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 afterdeleteDocuments(term).- Observed
expected:<0> but was:<33040>; with-ea,close()also throwsnetBytes has unexpected value 0 != 33040.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on 10.6.0 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# 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- 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, andTestFlushByRamOrCountsPolicyassertsactiveBytes() == 0after close.
The full argument190 words
- Accepted input. The javadocs of
updateDocuments(IndexWriter.java:1593-1604) andsoftUpdateDocuments(:1644-1663) do not require a non-empty block;softUpdateDocumentsvalidates 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.abortassertsflushControl.netBytes() == 0(DocumentsWriter.java:232-233);TestFlushByRamOrCountsPolicyassertsflushControl.activeBytes() == 0afterwriter.close()(TestFlushByRamOrCountsPolicy.java:102-103);setFlushPending,doAfterFlushanddoOnAbortall 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.
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.
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, theaddDocuments(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,forbiddenApisMainandforbiddenApisTestof:lucene:corepass (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).
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.
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.
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).
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
softUpdateDocumentsedge cases. A probe (softUpdateDocuments(term, List.of(), soft), thenclose()) failed under-eainDocumentsWriter.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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr) | 2026-10-26 | the 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 document | no report; the unanswered #1397 review question; #5464, #10080, #12573 (different bugs) |
| Open PRs | 2026-10-26 | changed files of all 408 open PRs; the 5 closest diffs of the 24 that touch an affected file read | none changes the 0-document branch |
| JIRA archive, mailing lists, downstream, web | 2026-10-26 | 12 extra JIRA queries (LUCENE, SOLR); dev@, java-user@, issues@, commits@; Elasticsearch, OpenSearch, Solr; CHANGES.txt and git log -S over the full history | no report |
| GitHub re-check | 2026-10-31 | 14 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-26 | no report; #16867 (merged 2026-10-30) touches the flush control but not the drop branch |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails through gradle, assertions on and off (33,040 bytes) |
| 10.6.0 | affected | the minimal test fails on the release jars, assertions on and off |
| 9.9.0-10.5.2 | affected | 33,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 default | updateDocuments: 0 bytes with the default config, 165 with setMaxBufferedDocs; softUpdateDocuments: about 33 KB per round |
| 7.7.0-9.5.x | affected | softUpdateDocuments: about 33 KB per round; updateDocuments: 165 bytes |
| 4.5.0-7.6.x | small leak | 165 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 loudly | the same call throws a NullPointerException |
| fix (main; same shape on 4.5.0) | not affected | 0 bytes; documents flush normally |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Its 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), procedural | Read-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 | real | Own 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 only | Read-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, minor | Only 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), minor | Own default-config test: 660,800 bytes after 20 rounds (= 20 x 33,040), delete control 0; the fix passes. |
| history | since 4.5.0, corrected | Probe on 23 releases plus main; corrected the candidate's "no leak before 9.9". |
| audits | keep; minimal test verified | Credibility 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 validated | 10.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 fixed | An 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 464 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,629 words
- 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
645d4fe14a20through 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 mainea57360912ed(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 main2f424fd428d(2026-10-30),branch_11x,branch_10_6and the 10.6.0 tag have the same drop branch.
- 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-1675a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:232-233, 412-454, 456-491, 667-680a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:245-301, 361-390a 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-46a 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-602a 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-1675a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:157-190a 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-602a 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)
- TestEmptyBlockUpdateRamAccounting.java (one test, public API):
repro/LFV-453/src/org/apache/lucene/index/TestEmptyBlockUpdateRamAccounting.java(35 lines)
Made in parallel with this doc, from the code at 645d4fe14a20 and the executed runs.
assets/LFV-453/bug.png(464 KB)
