All findings · Lucene finding · LFV-1232 · termvectors-writer-rambytesused-counts-int-buffers-as-bytes

The default term-vectors writer counts its int[] position and offset buffers at one byte per entry, so IndexWriter under-counts buffered term-vector heap

The default term-vectors writer reports its buffered positions and offsets to IndexWriter at one byte per int instead of four, so IndexWriter flushes later than configured. With 65,536 term-vector positions in one document, IndexWriter reports 356,924 bytes, below the 786,432 those buffers need; data and results stay correct.

Summary

Priority
Low, rank 996Every application that stores term vectors with positions or offsets on the default codec has its per-thread RAM under-counted, so flushes come later than ramBufferSizeMB intends, in every release since 8.7.0. The gap stays small unless one chunk buffers a very long document, and no index data or search result is wrong.
Impact
minor
Since
8.7.0 (LUCENE-9514, 2020)introduced 2020-09-14 · about 6 years
Affected
8.7.0 to 10.6.0 and main; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of the writer's Accountable#ramBytesUsed contract, witnessed by an executable repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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 starts a flush, of its largest thread's buffered documents, when the heap that all buffered documents and deletes use, summed from each component's ramBytesUsed(), reaches ramBufferSizeMB (16 MB by default); each thread also has a 1945 MB hard limit. For fields with term vectors, the term-vectors writer is one of those components, and it reports its biggest buffers in the wrong unit.

The repro's document (the word x 65,536 times, term vectors with positions and offsets) fills the term-vectors writer's positionsBuf, startOffsetsBuf and lengthsBuf to 73,728 int entries each, 4 bytes per entry; ramBytesUsed() at Lucene90CompressingTermVectorsWriter.java:993-1003 adds the array lengths, counting one byte per entry; IndexWriter.ramBytesUsed() is 356,924 bytes, below the 786,432-byte bound, and 1,023,612 with RamUsageEstimator.sizeOf per buffer.
One addDocument with 65,536 term-vector positions: the writer's int[] buffers hold 888,896 bytes, but ramBytesUsed() counts their 222,208 entries as bytes, so IndexWriter reports 356,924 in total (1,023,612 with the fix). Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index one document whose field stores term vectors with positions and offsets, text: the word "x" 65,536 times (default codec and IndexWriterConfig).
  2. The term-vectors writer keeps the not-yet-written positions, start offsets and lengths in int[] buffers, here 73,728 entries each, 888,896 bytes in all with the payload-length buffer. You expect IndexWriter.ramBytesUsed() to be at least 786,432.
  3. You get 356,924 for the whole IndexWriter. With the fix it reports 1,023,612, so 666,688 bytes were missing.
  4. Why: the writer's ramBytesUsed() adds positionsBuf.length + startOffsetsBuf.length + lengthsBuf.length + payloadLengthsBuf.length, array lengths, as if each int took one byte.
  5. Effect: IndexWriter decides when to flush from this number, summed over its threads, so flushes come later than ramBufferSizeMB intends. With about a million positions in one document, 10.6 MB go uncounted. Index data and results stay correct.

Who is affected

Any application with a term-vector field that stores positions or offsets, on the default codec, through plain IndexWriter.addDocument (FastVectorHighlighter needs them). With positions and offsets, about 9 bytes per buffered position go uncounted: about 12 KB per fresh writer, megabytes once a pending chunk holds about 10^5 positions, typically one very long document. Data and results stay correct.

Mechanism, terms and numbers589 words

Since 8.7.0 (LUCENE-9514), IndexWriter adds the term-vectors writer's ramBytesUsed() to each indexing thread's RAM, so that flush and stall decisions see the heap it holds (IndexingChain.java:1792-1797, CHANGES.txt:5627-5629 at 5adff78bb168). The default writer, Lucene90CompressingTermVectorsWriter, keeps the positions, start offsets, lengths and payload lengths of not-yet-written documents in four int[] buffers (:225), but its ramBytesUsed() adds their .length values (:993-1003), as if each int took one byte, so three quarters of that memory is never counted. With one document of 65,536 term-vector positions and offsets, IndexWriter.ramBytesUsed() is 356,924 bytes, below the 786,432 the three position/offset arrays need at minimum; with RamUsageEstimator.sizeOf per buffer it is 1,023,612. The stored-fields writer, added to the same accounting a week earlier, multiplies by Integer.BYTES. No index data or search result is wrong; IndexWriter only flushes later than ramBufferSizeMB and the per-thread hard limit intend.

The bug, step by step

  1. Buffers. Lucene90CompressingTermVectorsWriter declares private int[] positionsBuf, startOffsetsBuf, lengthsBuf, payloadLengthsBuf; (:225 at 5adff78bb168), allocates each as new int[1024] (:295-298) and grows them as positions, start offsets and lengths are buffered, and payload lengths only for fields with payloads (addPosition :194-217, addProx :734-788). Nothing shrinks them until the segment flushes.
  2. A pending chunk keeps all its positions. A chunk is written only once its term suffixes and payload bytes reach 4 KiB (finishDocument appends the payload bytes to the suffixes, :323-329) or it holds 128 documents (:379-381; Lucene90TermVectorsFormat.java:170). Until then the positions of all its documents sit in the buffers at once: one long document with few distinct terms is enough, and up to 128 shorter ones add up the same way. Writing a chunk clears the pending documents but keeps the grown buffers (:429-434).
  3. Through IndexWriter. TermVectorsConsumerPerField calls addProx(freq, ...) once per term (TermVectorsConsumerPerField.java:119); positionsBuf grows through ArrayUtil.grow and startOffsetsBuf and lengthsBuf through ArrayUtil.growExact(..., ArrayUtil.oversize(n, 4)); both give ArrayUtil.oversize(n, 4) = 73,728 entries for n = 65,536. Without payloads, payloadLengthsBuf keeps its 1,024.
  4. The count. ramBytesUsed() (:993-1003) returns positionsBuf.length + startOffsetsBuf.length + lengthsBuf.length + payloadLengthsBuf.length + termSuffixes.ramBytesUsed() + payloadBytes.ramBytesUsed() + lastTerm.bytes.length + scratchBuffer.ramBytesUsed(): four element counts of int[] next to four byte counts.
  5. Where the number goes. TermVectorsConsumer creates the writer when a thread first indexes a term-vector field and sets accountable = writer (TermVectorsConsumer.java:103-109); IndexingChain.ramBytesUsed() adds it (IndexingChain.java:1795); DocumentsWriterPerThread.ramBytesUsed() adds the chain (DocumentsWriterPerThread.java:718-723). DocumentsWriterFlushControl adds each thread's change to activeBytes, the RAM of all active threads (DocumentsWriterFlushControl.java:244); the flush policy compares activeBytes plus buffered deletes with ramBufferSizeMB and marks the largest thread for flushing (FlushByRamOrCountsPolicy.java:45-55, :84); the per-thread hard limit checks the thread's own total (DocumentsWriterFlushControl.java:247). IndexWriter.ramBytesUsed() reports delete bytes plus the committed bytes of all threads (IndexWriter.java:796-799, DocumentsWriter.java:748-750, DocumentsWriterFlushControl.java:513-516): with one thread and no deletes, as here, this thread's total.
input (one document, one term)buffer entries (3 arrays + payload)int[] heapcounted by the writerIndexWriter.ramBytesUsed() stockwith option A
fresh writer3 x 1,024 + 1,02416,4484,096
65,536 positions and offsets3 x 73,728 + 1,024888,896222,208356,9241,023,612
1,048,576 positions and offsets3 x 1,179,648 + 1,02414,159,9363,539,9684,657,72415,277,692

The two IndexWriter columns are measured; the differences, 666,688 and 10,619,968 bytes, equal 3 bytes per buffer entry plus four array headers of 16 bytes, exactly as the code predicts. The heap and counted columns follow from the capacities by reading.

Exposure and likelihood262 words
  • Reach. The default Lucene104Codec uses Lucene90TermVectorsFormat (Lucene104Codec.java:71). Term-vector positions and offsets are an opt-in field option; the FastVectorHighlighter requires them and advertises itself as "fast for large docs" (vectorhighlight/package-info.java:24, :28).
  • Effect. ramBufferSizeMB (default 16 MB, IndexWriterConfig.java:83) is compared with the RAM of all threads, so flushes come later than it intends, and a thread reaches the per-thread hard limit (default 1945 MB, :93) later. With the default buffer, one document of about a million term-vector positions with offsets holds 10.6 MB the flush policy does not see (measured).
  • Scale. 3 bytes per int of buffer capacity, for each buffer the field uses: per buffered position about 3 bytes with positions only, 6 with offsets only, 9 with both (about 10 with growth slack), 12 with payloads as well. About 12 KB per term-vectors writer from the start (four int[1024] counted as 4,096 bytes instead of 16,448); the writer exists once a thread indexes a term-vector field, one per thread's segment. Megabytes once one pending chunk has buffered on the order of 10^5 positions or more: typically one very long document, but many repetitive shorter ones in one chunk do it too (127 documents of 8,192 positions: 9,956,416 bytes uncounted, measured). The gap then lasts until the segment flushes.
  • Not a cap, before or after the fix. ramBufferSizeMB stays an indicator of when to flush, and a flush may release only part of the buffer (LiveIndexWriterConfig.java:167-181); the fix makes that indicator count these buffers correctly.
  • Not affected. Indexed data, segment files and search results.

Minimal repro

One JUnit test method on the public API: one IndexWriter.addDocument of a field with term vectors, positions and offsets, holding the word "x" 65,536 times. Deterministic, single-threaded, about a second; it fails with assertions on and off and passes with either fix.

TestTermVectorsRamBytesUsed.java (one test, public API)46 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.document.Field;
import org.apache.lucene.document.FieldType;
import org.apache.lucene.document.TextField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestTermVectorsRamBytesUsed extends LuceneTestCase {

  public void testBufferedTermVectorPositionsAreCounted() throws Exception {
    // Expected: ramBytesUsed() >= the 3 int[n] buffers (positions, start offsets, lengths);
    // actual: their lengths are counted as bytes.
    int n = 1 << 16;
    FieldType ft = new FieldType(TextField.TYPE_NOT_STORED);
    ft.setStoreTermVectors(true);
    ft.setStoreTermVectorPositions(true);
    ft.setStoreTermVectorOffsets(true);
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w =
            new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
      w.addDocument(List.of(new Field("f", "x ".repeat(n), ft)));
      long buffered = 3L * n * Integer.BYTES;
      assertTrue(w.ramBytesUsed() + " < " + buffered, w.ramBytesUsed() >= buffered);
    }
  }
}
Command
cp $REPRO/TestTermVectorsRamBytesUsed.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestTermVectorsRamBytesUsed -Ptests.seed=5EED1232 -Ptests.asserts=true
# and with -Ptests.asserts=false (assertions off)
Expected
IndexWriter.ramBytesUsed() is at least 786,432 bytes, the three buffered int[] of at least 65,536 entries each (with the fix: 1,023,612).
Observed
AssertionError: 356924 < 786432, with assertions on and off.

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

More files, environment, full output156 words

The bound is conservative: it counts only three arrays at exactly n entries, ignoring growth slack, the payload-length buffer, array headers and everything else IndexWriter holds. It is a lower bound, not an exact heap count: an unrelated rise in IndexWriter's other accounting could in principle make it pass, so the measured probe values above pin the exact numbers. Pass -Ptests.asserts explicitly; the result depends on neither setting.

As a regression test, the same method fits into the existing lucene/core/src/test/org/apache/lucene/index/TestTermVectorsWriter.java.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): this test fails; earlier tests with the same root cause also fail on 8.7.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and 10.6.0
jdkOpenJDK 25.0.3
osLinux aarch64 (nothing platform-specific)

Observed output

TestTermVectorsRamBytesUsed > testBufferedTermVectorPositionsAreCounted FAILED
    java.lang.AssertionError: 356924 < 786432
        at org.apache.lucene.index.TestTermVectorsRamBytesUsed.testBufferedTermVectorPositionsAreCounted(TestTermVectorsRamBytesUsed.java:43)

# main 5adff78bb168, gradle, seed 5EED1232: the same failure with -Ptests.asserts=true and =false
# with option A or option B: BUILD SUCCESSFUL, 1 test, 0 failures, assertions on and off

Why it looks unintended

  • Accountable#ramBytesUsed returns "an estimate of the JVM heap memory used by this object in bytes"; LUCENE-9514 added this method so IndexWriter's flush and stall decisions count the writer's heap.
  • The same return adds real byte counts next to the int[] lengths.
  • The stored-fields writer, added a week earlier by the same author, multiplies its int[] lengths by Integer.BYTES.
The full argument143 words
  • Contract. "Returns an estimate of the JVM heap memory used by this object in bytes" (util/Accountable.java:35).
  • Purpose. The introducing commit 8f895d90755 (LUCENE-9514): "TermVectorsWriter might consume some heap space memory that can have a significant impact on decisions made in the IW if writers should be stalled or DWPTs should be flushed if memory settings are small in IWC and flushes are frequent"; CHANGES, under 8.7.0: "to ensure that it's heap consumption is taken into account when IndexWriter stalls or should flush DWPTs" (CHANGES.txt:5627-5629).
  • Sibling. 98e55f0ea82 (LUCENE-9511, 2020-09-08, same author) counted the stored-fields writer's int[] as length * Integer.BYTES; #11923 later widened it to (long) Integer.BYTES (Lucene90CompressingStoredFieldsWriter.java:726-730) and left the term-vectors method alone.
  • No record of a choice found. We found no comment, test, CHANGES or MIGRATE entry, or review comment on apache/lucene-solr#1847 that mentions counting per element.
The devil's-advocate check171 words

The strongest case for "intended": Accountable is @lucene.internal (Accountable.java:30) and promises only "an estimate", the writer is @lucene.experimental (Lucene90CompressingTermVectorsWriter.java:63), and the RAM-buffer docs say an IndexWriter "can consume a significantly larger amount of memory than the given RAM limit since this limit is just an indicator when to flush" (LiveIndexWriterConfig.java:167-171).

Why it fails: an estimate may skip headers or small objects; counting the dominant owned payload in element units is a 4x unit error. The same javadoc says "the writer will flush whenever buffered documents and deletions use this much RAM" (:162-163). The javadoc does not document this per-element accounting; concurrent and partial flushing (:167-181) separately mean the setting is not a hard heap cap. The tags limit API stability, not the correctness of a number IndexWriter acts on, and the trigger is ordinary IndexWriter.addDocument. The independent reviewer from another model family rated the intended case weak in both its refute and its intended-behaviour pass. Verdict: a real Low bug.

Suggested fix

Recommended A. RamUsageEstimator.sizeOf per buffer (validated)

Count each buffer with RamUsageEstimator.sizeOf(positionsBuf) (and the same for the other three) instead of .length: one file, five lines. The repro passes with assertions on and off, and 82 term-vector and flush-policy tests pass. Alternative: length * (long) Integer.BYTES, the stored-fields writer's idiom.

+import org.apache.lucene.util.RamUsageEstimator;
...
-    return positionsBuf.length
-        + startOffsetsBuf.length
-        + lengthsBuf.length
-        + payloadLengthsBuf.length
+    return RamUsageEstimator.sizeOf(positionsBuf)
+        + RamUsageEstimator.sizeOf(startOffsetsBuf)
+        + RamUsageEstimator.sizeOf(lengthsBuf)
+        + RamUsageEstimator.sizeOf(payloadLengthsBuf)
All options and trade-offs2 options ยท 310 words

Both options were applied in a scratch worktree at 5adff78bb168 and run through gradle; the existing suites were run with option A.

A. RamUsageEstimator.sizeOf per buffer (validated) (recommended)

Lucene90CompressingTermVectorsWriter.java:993-1003 plus one import: 1 file, 5 insertions, 4 deletions.

+import org.apache.lucene.util.RamUsageEstimator;
...
-    return positionsBuf.length
-        + startOffsetsBuf.length
-        + lengthsBuf.length
-        + payloadLengthsBuf.length
+    return RamUsageEstimator.sizeOf(positionsBuf)
+        + RamUsageEstimator.sizeOf(startOffsetsBuf)
+        + RamUsageEstimator.sizeOf(lengthsBuf)
+        + RamUsageEstimator.sizeOf(payloadLengthsBuf)

Trade-offs

Counts the array headers too, as Lucene's other accounting does. ramBytesUsed() is called after each document (DocumentsWriterFlushControl.java:216; the change is committed once it is large enough, :218-223); the fix adds four small computations, nothing per position. Writers with many term-vector positions now report more and flush earlier, closer to their configured RAM, which is the point; ramBufferSizeMB stays an indicator, not a heap cap.

What the checks validated

  • The repro passes with assertions on and off (gradle, seed 5EED1232).
  • With the fix, TestCompressingTermVectorsFormat, TestLucene90TermVectorsFormat, TestTermVectors, TestTermVectorsReader, TestTermVectorsWriter, TestMultiThreadTermVectors and TestFlushByRamOrCountsPolicy pass with assertions on (8 classes with the repro, 82 tests, 0 failures, 0 skipped).
  • :lucene:core:checkGoogleJavaFormat passes with the fix and the test file.
  • IndexWriter.ramBytesUsed() rises from 356,924 to 1,023,612 for the repro's input, and from 4,657,724 to 15,277,692 at 2^20 positions; the differences equal 3 bytes per buffer entry plus four array headers.

B. length * (long) Integer.BYTES per buffer (repro validated)

The stored-fields writer's idiom (Lucene90CompressingStoredFieldsWriter.java:728-729): positionsBuf.length * (long) Integer.BYTES and the same for the other three; 4 lines, no import.

-    return positionsBuf.length
+    return positionsBuf.length * (long) Integer.BYTES
-        + startOffsetsBuf.length
+        + startOffsetsBuf.length * (long) Integer.BYTES
-        + lengthsBuf.length
+        + lengthsBuf.length * (long) Integer.BYTES
-        + payloadLengthsBuf.length
+        + payloadLengthsBuf.length * (long) Integer.BYTES

Trade-offs

Omits the four array headers (16 bytes each on this JVM), negligible. The repro passes with assertions on and off; the existing suites were run with option A only.

Downstream note

Products that index very long documents into term-vector fields with positions or offsets, with a heap sized close to the RAM buffer, can use noticeably more heap than their flush settings suggest until it is fixed.

How it was found

A proof-obligation audit of the term-vectors writer's Accountable#ramBytesUsed contract: the method must report the heap it holds in bytes. Checking each term of its sum against the field types showed four int[] lengths added as bytes. A one-method JUnit test through IndexWriter.addDocument witnessed it.

The method in full71 words

The obligation came from the Accountable#ramBytesUsed javadoc and the purpose stated in LUCENE-9514, checked term by term against the declared types of the summed fields (:225). The first witness drove the writer directly with one term of 2^20 positions (ramBytesUsed()=3217630 while three buffered arrays need at least 12,582,912 bytes, main with assertions on and off and 10.6.0); the minimal repro then showed the same through public IndexWriter API.

Prior reports, history and checks

Prior reports

No matching report found in the recorded searches of apache/lucene issues, PRs and commits (incl. the JIRA archive), the mailing lists, downstream trackers and the web (2026-11-03). Nearest: #10553, the change that added the method, and #10550, the stored-fields sibling that counts Integer.BYTES.

Searches3 searches

Nearest items, none of them this bug: #10553 (LUCENE-9514, origin; its PR review does not discuss buffer sizing), #10550 (LUCENE-9511, the stored-fields sibling), #10143 (LUCENE-9101, open since 2019: lengthsBuf is redundant, a memory idea that predates the method), #16113 and draft #16114 (open: Accountable ownership semantics, explicitly not bug reports), #15982 and #16405 (the latter fixed by PR #16406; RAM under-count fixes in other classes). No open PR changes this method. Keyword searches cannot see private reports or every review comment.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs, comments and commits (apache/lucene incl. the migrated JIRA archive, apache/lucene-solr)2026-11-03, re-checked the same daythree scripted prior-report searches (45 executed queries each over GitHub issues and PRs, commits, downstream repositories, the mailing lists and the JIRA archive; their 6 web queries each were not executed by the script) and 30 manual searches: positionsBuf.length, payloadLengthsBuf, CompressingTermVectorsWriter ramBytesUsed, TermVectorsWriter Integer.BYTES, RAM-buffer and flush symptoms; git log -S 'positionsBuf.length'no report; #10553, #10550, #10143, #16113/#16114 are context
Open PRs2026-11-03the changed files of all 410 open apache/lucene PRs and the 60 most recently updated closed ones#1003 and #16480 edit the writer and #16269 edits TermVectorsConsumer, but none touches ramBytesUsed or the buffer sizing
Mailing lists, downstream, web2026-11-03dev@, java-user@, issues@, commits@ (11 manual queries); Solr, Elasticsearch, OpenSearch; 11 manual web searches (3 throttled and re-phrased)nothing about this under-count

History

Introduced by LUCENE-9514 (8f895d90755, apache/lucene-solr#1847, 2020), which first made the term-vectors writer count its heap; first released in 8.7.0. 8.7.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2, 10.6.0 and main fail; 8.6.3 lacks the method. Not fixed upstream.

Versions tested4 rows

8f895d90755 "LUCENE-9514: Include TermVectorsWriter in DWPT accounting (#1847)" (Simon Willnauer, 2020-09-14; branch_8x 597d8f69522, released in 8.7.0) made TermVectorsWriter Accountable and wrote the int[] length sum. Later commits only reformatted it (2d6ad2fee6d, LUCENE-9570), renamed the class (f8be421ae1f, LUCENE-9705, 9.0.0) and added scratchBuffer.ramBytesUsed() (873ac5f162a, LUCENE-9907). The body is identical at all 34 9.x and 10.x release tags, and the 8.8.0 and 8.8.1 tags contain the branch_8x commit; 8.8.2 through 8.11.3 are inferred, not run. Before 8.7.0 IndexWriter did not count the term-vectors writer at all, so this is not a regression of working code. Upstream main 1c2072f0284, branch_11x and branch_10_6 still sum the lengths (2026-11-03).

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle: fails with assertions on and off (356,924 < 786,432); passes with either fix
10.6.0, 10.5.2, 9.12.3 affectedfails (writer level: 3,217,630 reported vs at least 12,582,912 buffered)
9.0.0, 8.11.4, 8.7.0 affectedfails (3,217,630 on 9.0.0; 3,221,802 on 8.x); 8.7.0 is the first release with the method
8.6.3 no APIthe method does not exist and IndexWriter does not count the term-vectors writer at all (an older, different gap)

Independent checks

An independent reproducer (real, Low), an executed cross-model review in two passes, refute and intended (an independent reviewer from another model family, OpenAI GPT-5.6; real, high), a devil's-advocate verifier (real, high) and an adjudication (confirmed, Low) agree, and separate re-runs reproduced it. This doc's gradle run reproduced it on main and validated both fixes.

Checks and verdicts318 words

Both passes of the cross-model review ran their Java on main ea57360912ed, where the writer and TermVectorsConsumer files are identical to 5adff78bb168's and the IndexingChain and flush-control changes in between do not touch the per-thread RAM accounting.

Each independent check and its verdict
CheckVerdictWhat it found
the audit's own repros reproducedWriter-level test: ramBytesUsed()=3217630 while three buffered int[] need at least 12,582,912 bytes, on main (assertions on and off) and 10.6.0; the minimal IndexWriter repro: 356,924 < 786,432; both pass with option A.
independent reproducer real, LowOwn writer-level test fails on main, 10.6.0, 10.5.2 and 9.12.3; own IndexWriter test fails on main; 9,649,750 bytes missing at 2^20 positions (writer level); both pass with the fix.
cross-model review, refute and intended passes (an independent reviewer from another model family, OpenAI GPT-5.6, executed) real (high)Own tests fail with assertions on and off while a no-positions control passes; through IndexWriter.addDocument with 262,144 positions the writer reports 299,052 bytes, below the 1,048,576 its position array alone needs; with the fix TestCompressingTermVectorsFormat passes 22 of 22; intended case rated weak.
devil's-advocate verifier real, highRebutted "only an estimate", "the RAM buffer is just an indicator" and the earlier removal of reader-side accounting (LUCENE-9387, a different side); own probe: at 2^18 positions term vectors add 886,828 bytes to IndexWriter.ramBytesUsed() while their three arrays need at least 3,145,728.
adjudication and separate re-runs confirmed, LowRe-runs fail on main with assertions on and off and pass with the fix; one minimal deterministic test through public API; silent, default settings, but no wrong data, hence Low.
this doc's fix check reproduced; options A and B validatedGradle in a scratch worktree at 5adff78bb168: the repro fails without and passes with either option; 82 tests in 8 related classes pass with option A; a probe shows the IndexWriter total rising by exactly the uncounted bytes.

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

Finding record

LFV-1232, snapshot of 2026-11-06

Registry title
Lucene90CompressingTermVectorsWriter.ramBytesUsed() counts its four int[] position and offset buffers at one byte per entry, so IndexWriter under-counts buffered term-vector heap (about 9.6 MB missing for one pending document with 2^20 term-vector positions and offsets)
Area
codecs / term vectors (Lucene90 compressing term-vectors writer; IndexWriter per-thread RAM accounting)
Priority
Low, rank 996: Every application that stores term vectors with positions or offsets on the default codec has its per-thread RAM under-counted, so flushes come later than ramBufferSizeMB intends, in every release since 8.7.0. The gap stays small unless one chunk buffers a very long document, and no index data or search result is wrong.
Impact class
minor
Impact note
Any application that stores term vectors with positions or offsets on the default codec (8.7.0 through 10.6.0 and main) has each indexing thread's RAM under-counted by 3 bytes per int of these buffers, so that thread flushes, and reaches its per-thread hard limit, later than configured; the gap is about 12 KB for a fresh writer and grows to megabytes only after one chunk has buffered a very long document (the buffers never shrink until the segment flushes); no index data or search result is wrong.
Summary
You index documents with a field that stores term vectors with positions or offsets (default codec and IndexWriterConfig) and expect IndexWriter's RAM accounting, which since 8.7.0 adds the term-vectors writer's ramBytesUsed() so that flush and stall decisions see its heap, to count the writer's buffered positions and offsets in bytes. Instead Lucene90CompressingTermVectorsWriter.ramBytesUsed() adds the lengths of its four int[] buffers (positions, start offsets, lengths, payload lengths) as if each entry took one byte: with one pending document holding one term with 2^20 positions and offsets, the writer reports 3,217,630 bytes while those three int[] alone take at least 12,582,912 bytes (with the one-line fix it reports 12,867,380). Indexed data and search results are unaffected; IndexWriter only flushes later than ramBufferSizeMB and the per-thread hard limit intend.
Introduced
8f895d90755f474dcad1d4e8b1a1b8763536f5f6 on main ('LUCENE-9514: Include TermVectorsWriter in DWPT accounting (#1847)', Simon Willnauer; issue #10553 = LUCENE-9514, PR apache/lucene-solr#1847); the released code came from the same change on branch_8x, 597d8f69522, first in 8.7.0. It made TermVectorsWriter Accountable and added CompressingTermVectorsWriter.ramBytesUsed() as positionsBuf.length + startOffsetsBuf.length + lengthsBuf.length + payloadLengthsBuf.length + byte counts, where the four buffers are int[]; the only later commits on the method are 2d6ad2fee6d (LUCENE-9570, reformatting) and 873ac5f162a (LUCENE-9907, adds scratchBuffer.ramBytesUsed()), and the 9.0 rename to Lucene90CompressingTermVectorsWriter (f8be421ae1f, LUCENE-9705) kept the body [read: git log -L and git blame of main 5adff78bb168; exec: 8.6.3 lacks the method (compile error) and 8.7.0 fails] (2020-09-14 (author and merge date of 8f895d90755 on main; the branch_8x commit 597d8f69522 has the same author date and commit date 2020-09-14); first release 8.7.0)
First affected version
8.7.0 [exec]; 8.6.3 is the last release without it [exec: TermVectorsWriter is not Accountable there (compile error on ramBytesUsed()) and IndexWriter does not count the term-vectors writer at all, an older and different gap: IndexWriter.ramBytesUsed()=74,088 with at least 12.6 MB buffered]
Affected versions
8.7.0 through 8.11.4, every 9.x release (9.0.0-9.12.3), every 10.x release through 10.6.0 (4ae946e5a9b) and main 5adff78bb168. Executed and failing: 8.7.0, 8.11.4 and 9.0.0 (-ea; 8.x writer reports 3,221,802 bytes, 9.0+ 3,217,630), 9.12.3 (-ea, -da), 10.5.2 (-ea, -da), 10.6.0 (-ea, -da) and main (-ea, -da), with the finder's repro, the independent reproduction's test or the history step's version-adapted copy [exec]; the method body is identical (by hash of the normalized body) at all 34 9.x and 10.x release tags, and the 8.8.0 and 8.8.1 tags contain 597d8f69522 [read]; 8.8.2-8.11.3 are not tagged in the history clone and were not run (inferred affected, as 8.7.0, 8.8.1 and 8.11.4 have it). Not affected: 8.6.3 and older (the method does not exist) [exec 8.6.3]. Not fixed upstream: upstream main 1c2072f0284, branch_11x 6c5889ee89f and branch_10_6 5deb5791f6c still sum the lengths (GitHub contents API, 2026-11-03; main re-checked). Not new in 10.6.
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
semi-formal: proof-obligation audit
Upstream and prior reports
none found (searched 2026-11-03 and rechecked the same day: three scripted prior-report searches with different specs (the finder's; buffer identifiers and fix vocabulary; flush and OutOfMemoryError symptoms) over apache/lucene issues, PRs, comments and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web (51 queries each; 1,059, 1,067 and 1,291 distinct hits, 1 high hit, unrelated, all medium hits read); 30 manual GitHub issue and commit searches across apache/lucene, apache/lucene-solr, Solr, Elasticsearch and OpenSearch, including items created or updated since 2026-10-25; 11 manual mailing-list queries; the changed files of all 410 open apache/lucene PRs (#1003, #16480 and #16269 touch term-vectors files but not ramBytesUsed or the buffer sizing) and the 60 most recently updated closed PRs; the --follow history of the file; 9 web queries; the independent reproduction's, the intended-behaviour check's and the adjudication's searches agree). Nearest items, not reports of this bug: #10553 (LUCENE-9514, the introducing issue, and the review of apache/lucene-solr#1847, neither discusses buffer sizing), #10550 (LUCENE-9511, the stored-fields sibling, which uses Integer.BYTES), #10143 (LUCENE-9101, open, lengthsBuf redundancy, a memory idea that predates the method), #16113 and draft #16114 (open, Accountable ownership semantics), #15982 and #16405 (RAM under-count fixes in other classes). Re-checked 2026-11-03: no new item.
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT): gradle in a scratch worktree for this doc. The one-test repro fails with assertions on and off (356,924 < 786,432) and passes with option A (RamUsageEstimator.sizeOf per buffer) and with option B (length * (long) Integer.BYTES); with option A, 82 tests in 8 term-vector and flush-policy classes pass and the formatter check passes. A supporting probe measured IndexWriter.ramBytesUsed() stock and with option A (356,924 / 1,023,612 at 2^16 positions; 4,657,724 / 15,277,692 at 2^20). Earlier tests with the same root cause fail on 8.7.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and 10.6.0; 8.6.3 lacks the method.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingTermVectorsWriter.java:993-1003 (at 5adff78bb168) (ramBytesUsed: positionsBuf.length + startOffsetsBuf.length + lengthsBuf.length + payloadLengthsBuf.length + termSuffixes.ramBytesUsed() + payloadBytes.ramBytesUsed() + lastTerm.bytes.length + scratchBuffer.ramBytesUsed()), :225 (the four fields are int[]), :295-298 (new int[1024] each), :194-215 (addPosition grows them per buffered position, start offset, length and payload length), :379-381 (triggerFlush: a chunk is written only at chunkSize bytes of term suffixes or maxDocsPerChunk documents), :736-778 (addProx grows the same buffers)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingStoredFieldsWriter.java:726-730 (at 5adff78bb168) (the stored-fields sibling counts its int[] as length * (long) Integer.BYTES)
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1792-1797 (at 5adff78bb168) (ramBytesUsed adds termVectorsWriter.accountable.ramBytesUsed()); index/TermVectorsConsumer.java:104-110 (accountable = writer at :108); index/DocumentsWriterPerThread.java:718-723 (adds indexingChain.ramBytesUsed()); index/DocumentsWriterFlushControl.java:244-251 (activeBytes and the per-thread hard limit check at :247)
  • lucene/core/src/java/org/apache/lucene/util/Accountable.java:30-40 (at 5adff78bb168) (@lucene.internal; 'Returns an estimate of the JVM heap memory used by this object in bytes'); index/LiveIndexWriterConfig.java:162-169 ('the writer will flush whenever buffered documents and deletions use this much RAM'; 'this limit is just an indicator when to flush'); codecs/lucene90/Lucene90TermVectorsFormat.java:76-77 (data accumulates in memory until the term and payload buffer grows beyond 4KB); lucene/CHANGES.txt:5627-5629 (LUCENE-9514 entry)
  • a Lucene main checkout: git diff ea57360912ed 5adff78bb168 leaves Lucene90CompressingTermVectorsWriter.java and TermVectorsConsumer.java unchanged; its IndexingChain.java and DocumentsWriterFlushControl.java changes (2813efa40a3 #16867, a9779ef3c19 #16734) do not touch ramBytesUsed or the term-vectors path (2026-11-03)
The finding in 30 seconds
  • Lucene main 5adff78bb168: lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingTermVectorsWriter.java:225, :295-298, :194-217, :734-788, :323-329, :379-381, :429-434, :993-1003
  • Lucene main 5adff78bb168: lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90TermVectorsFormat.java:170 (chunk size 1 << 12, 128 docs per chunk)
  • Lucene main 5adff78bb168: lucene/core/src/java/org/apache/lucene/index/TermVectorsConsumerPerField.java:119; index/TermVectorsConsumer.java:103-110; index/IndexingChain.java:1792-1797; index/DocumentsWriterPerThread.java:718-723; index/DocumentsWriterFlushControl.java:244-251, :513-516; index/FlushByRamOrCountsPolicy.java:27-29, :45-55, :84; index/DocumentsWriter.java:748-750; index/IndexWriter.java:796-799
  • Lucene main 5adff78bb168: lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java (oversize: minTargetSize + minTargetSize >> 3, rounded to even for 4-byte elements)
  • Lucene main 5adff78bb168: lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:83 (DEFAULT_RAM_BUFFER_SIZE_MB = 16.0)
Who is affected
  • Lucene main 5adff78bb168: lucene/core/src/java/org/apache/lucene/index/TermVectorsConsumer.java:103-109; index/LiveIndexWriterConfig.java:167-181; index/FlushByRamOrCountsPolicy.java:45-55
  • Lucene main 5adff78bb168: lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104Codec.java:71
  • Lucene main 5adff78bb168: lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/package-info.java:24, :28
  • Lucene main 5adff78bb168: lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:83, :93
Why it looks unintended
  • Lucene main 5adff78bb168: lucene/core/src/java/org/apache/lucene/util/Accountable.java:30, :35
  • Lucene main 5adff78bb168: lucene/CHANGES.txt:5627-5629 (8.7.0, LUCENE-9514)
  • Lucene main 5adff78bb168: lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingStoredFieldsWriter.java:726-730
  • Lucene main 5adff78bb168: lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:162-181
  • Lucene main 5adff78bb168: lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingTermVectorsWriter.java:63 (@lucene.experimental), :993-1003
Suggested fix
  • Lucene main 5adff78bb168: lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:215-233; DocumentsWriterPerThread.java:788-790
Diagrams
  • Lucene main 5adff78bb168: lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingTermVectorsWriter.java:993-1003

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

Repro files

About the diagrams

One diagram of the repro's path, made separately; its notes are in the diagram directory.

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.