All findings · Lucene finding · LFV-837 · first-dv-update-per-field-buffered-twice

IndexWriter buffers the first doc-values update of each field twice

IndexWriter stores the first doc-values update of each field twice: a 1 MiB binary update counts 2,162,956 bytes against the RAM buffer (1,114,340 with the fix), about twice each later one, and an update matching 10 documents is applied 20 times. Values stay correct; every release since 7.7.0 is affected.

Summary

Priority
Low, rank 995On default config the first doc-values update of each field is buffered twice in every packet, but final values stay correct and the cost is only extra RAM accounting and apply work.
Impact
minor
Since
7.7.0 (LUCENE-8590, 2018)introduced 2018-12-04 · about 7 years
Affected
7.7.0 to 10.6.0 and main (10 of them run; 7.6.0 is clean)
Status
documented
Found by
semi-formal: arithmetic on an observed overflow index, then an executed probe and code reading

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

One update is stored twice when it creates its field's buffer.

Background: buffered doc-values updates

  1. updateNumericDocValue, updateBinaryDocValue and updateDocValues do not rewrite documents at once. Each update (a term, a field, a new value) waits in a per-field in-memory buffer until a flush, commit or NRT reopen applies it.
  2. Applying an update means finding the documents that match its term and recording the new value for each of them in memory; the recorded values are written to the index files later.
  3. Buffered updates count toward the writer's RAM buffer (ramBytesUsed(); 16 MB by default), which decides when the writer flushes.
Diagram of the bug: three 1 MiB binary updates of one field enter its update buffer. The first is stored twice (entries 0 and 1) and the later ones once, so ramBytesUsed() grows by 2,162,956, then 1,048,576 and 1,048,576 bytes; when applied, one update matching 10 documents is added 20 times to the pending in-memory updates, and the documents still end with the right value.
The first update of a field goes into its new buffer twice, once by the constructor and once by addUpdate; later updates go in once. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You add a document with a binary doc-values field payload, commit, then call updateBinaryDocValue twice on payload with 1 MiB values (terms that match no document) and read ramBytesUsed() after each call.
  2. You expect each call to add about 1 MiB (1,048,576 bytes): an update waits in memory, once, until the writer applies it.
  3. Instead the first call adds 2,162,956 bytes and the second 1,048,576. The field's update buffer already stores the update when it is created, and the caller then adds the same update again.
  4. The copy is applied too: one binary update matching 10 documents is added 20 times to the segment's pending in-memory updates (InfoStream: 1 field updates; 20 new updates). The 10 documents still end with the right value, because the copy is identical.
  5. This repeats whenever a field first appears in a batch of buffered updates. With large binary values the RAM buffer fills sooner, and the 2 GiB apply limit of LFV-838 is reached at roughly half the size (in the recorded run, 1,100 documents x 1 MiB).

Who is affected

Every application that updates doc values (updateNumericDocValue, updateBinaryDocValue, updateSortedNumericDocValue, updateDocValues, soft deletes), on the default configuration: the first update of each field in every batch is stored twice. It costs something only with large binary values: earlier flushes, twice the apply work, and LFV-838's writer close at roughly half the size. Values stay correct.

Mechanism, terms and numbers719 words

IndexWriter keeps doc-values updates in a per-field buffer (FieldUpdatesBuffer) until it applies them. When an update arrives for a field that has no buffer yet, BufferedUpdates.addNumericUpdate / addBinaryUpdate create the buffer with that update, and the constructor already records it as entry 0; the methods then call addUpdate (or addNoValue) with the same update, which stores it again as entry 1. This happens whenever a field first appears in a buffered-updates packet: the writer's global buffer and each per-thread buffer.

Effects on main 5adff78bb168: the first 1 MiB binary update of a field adds 2,162,956 bytes to IndexWriter.ramBytesUsed() and each later one 1,048,576, so a writer flushes sooner than its RAM buffer setting implies; when the packet is applied the copy is resolved and applied again, so every matching document gets a second pending in-memory update (InfoStream: 1 field updates; 20 new updates for one update matching 10 documents), and for a binary value that roughly halves the size at which LFV-838 closes the writer. Final values stay correct, because the copy is identical and adjacent, and only the last pending value per document is written. A numeric field whose updates all carry one value through one term field, with no unset update (soft deletes normally qualify), skips the copy when applying, but still stores it.

Lucene 7.6.0 stored each update once; the double insert came with LUCENE-8590 (b0e6a92ede3, 7.7.0). Creating the buffer with the first update and not adding that update again fixes it.

Where the copy comes from

  1. Public updates reach the buffer through DocumentsWriterDeleteQueue.java:500-511: NUMERIC and SORTED_NUMERIC go to BufferedUpdates.addNumericUpdate (:507), BINARY to addBinaryUpdate (:510).
  2. BufferedUpdates.java:146-156 (addNumericUpdate, :134-144, is the same) creates a missing buffer with fieldUpdates.computeIfAbsent(update.field, _ -> new FieldUpdatesBuffer(fieldUpdatesBytesUsed, update, docIDUpto)) and then always calls buffer.addUpdate(update.term, update.getValue(), docIDUpto) (:151) or buffer.addNoValue(update.term, docIDUpto) (:153); numFieldUpdates.incrementAndGet() (:155) counts the call once.
  3. The constructor already records the update: FieldUpdatesBuffer.java:49 private int numUpdates = 1;, :76 appends the term, :99 stores the numeric value, :111 appends the binary value. addUpdate (:182-200 numeric, :207-212 binary, value appended at :210) and addNoValue (:202-205) call append (:214-217), which appends the term again and returns numUpdates++: the copy is entry 1.

What the copy does

  1. RAM: the term bytes (and the value bytes, when the update has a value) are appended twice, so they count twice in ramBytesUsed().
  2. Apply: FrozenBufferedUpdates logs numFieldUpdates as "field updates" (:107, :214-218) but iterates every stored entry (:246-348): for the copy it seeks the term again (:267) and adds the value again for every matching live document (:342-343, counted as "new updates"). A binary value goes into BinaryDocValuesFieldUpdates.add (BinaryDocValuesFieldUpdates.java:94) twice per document, which is what roughly halves the LFV-838 threshold. These are pending in-memory updates: when they are written, the iterator keeps only the last update per document (DocValuesFieldUpdates.java:441-448), so the doc-values file gets each document once.
  3. Exception: finish() sorts the terms only for a numeric buffer with one shared value, no unset updates and one term field (FieldUpdatesBuffer.java:224, hasSingleValue :268-271); that iterator skips equal adjacent terms (:370-388), so there the copy is stored and counted but not applied again.

The numbers (main 5adff78bb168, public API, assertions off)

observablemainwith the fix
ramBytesUsed() delta of three 1 MiB binary updates of one field2,162,956 / 1,048,576 / 1,048,5761,114,340 / 1,048,616 / 1,048,576
InfoStream, one binary update matching 10 documents1 field updates; 20 new updates1 field updates; 10 new updates
the same after a non-matching update of the field2 field updates; 10 new updates2 field updates; 10 new updates
documents with the new value10 of 1010 of 10

With the fix the first update adds 1,114,340 bytes (1 MiB plus 65,764 bytes of one-time per-field setup) and the second 1,048,616, so on main the first update costs the fixed first update plus exactly one more entry (1,114,340 + 1,048,616 = 2,162,956). Numeric fields (earlier checks on main a9779ef3c193): two numeric updates with different values, the first matching 10 documents, give 20 new updates; a single numeric update (the one-value path) gives 10.

Exposure and likelihood242 words
  • Who is hit: callers of updateNumericDocValue (IndexWriter.java:1962), updateBinaryDocValue (:1996), updateSortedNumericDocValue (:2037), updateDocValues (:2070) and softUpdateDocument(s) (:1938, :1664), on any configuration, deterministically: one extra copy of the first update per field per packet (the global buffer and each per-thread buffer). tryUpdateDocValue (:1721) does not go through BufferedUpdates and is not affected.
  • Where it costs something: large binary values. The copy counts in the RAM buffer, so the writer flushes sooner than setRAMBufferSizeMB implies, and when applied the value is added twice per matching document to the pending in-memory updates. Through LFV-838 that roughly halves the size at which one binary update closes the writer: in the recorded run 1,100 documents x 1 MiB (1.07 GiB) closed it when the update was the field's first in the packet, and committed when another update of the field came first (assertions off, 8 GB heap; run in the original verification, not re-run for this doc).
  • Where it is cheap: for numeric updates and soft deletes the copy is little more than one term's bytes (an equal value, field and docIDUpto are not stored again while all entries share them, FieldUpdatesBuffer.java:135-180, :189); soft deletes normally carry one shared value, so they are not applied twice.
  • Not affected: final doc values; this bug alone throws nothing.
  • Priority Low: correct results, and the cost is bounded by one extra copy per field per packet.

Minimal repro

One JUnit test on the public API: two 1 MiB binary updates of the same field, with terms that match nothing, and ramBytesUsed() read before and after each. Deterministic, one thread, default configuration. It fails on main with assertions on and off, passes with the fix, and is the suggested regression test.

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

import org.apache.lucene.document.BinaryDocValuesField;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestFirstDocValuesUpdateBufferedTwice extends LuceneTestCase {

  // Two 1 MiB binary updates of the same field, with terms that match no document: each should
  // add about 1 MiB to ramBytesUsed(). The first one is buffered twice and adds about 2 MiB.
  public void testFirstUpdateIsAccountedOnce() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      Document doc = new Document();
      doc.add(new BinaryDocValuesField("payload", new BytesRef("v")));
      w.addDocument(doc);
      w.commit();
      BytesRef value = new BytesRef(new byte[1 << 20]);
      long r0 = w.ramBytesUsed();
      w.updateBinaryDocValue(new Term("id", "a"), "payload", value);
      long r1 = w.ramBytesUsed();
      w.updateBinaryDocValue(new Term("id", "b"), "payload", value);
      long r2 = w.ramBytesUsed();
      long first = r1 - r0, second = r2 - r1;
      assertTrue("second=" + second, second >= value.length);
      assertTrue("first=" + first + " second=" + second, first < second + value.length / 4);
    }
  }
}
Command
cp $REPRO/TestFirstDocValuesUpdateBufferedTwice.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestFirstDocValuesUpdateBufferedTwice -Ptests.seed=DEADBEEF
Expected
Each 1 MiB update adds about 1,048,576 bytes to ramBytesUsed(), and the test passes.
Observed
AssertionError: first=2162956 second=1048576: the first update is counted twice.

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

More files, environment, full output225 words

The update terms match no document, so the test measures only the buffering. It sits in org.apache.lucene.index to match Lucene's test layout; it uses no package-private API. The file carries the standard ASF license header and passes checkGoogleJavaFormat. It uses the 9.x test-framework package, so it does not compile against 7.x/8.x; those releases were checked with an equivalent plain-Java probe.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 9.12.3 and 10.6.0: fail; with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp $REPRO/TestFirstDocValuesUpdateBufferedTwice.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestFirstDocValuesUpdateBufferedTwice -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false

Observed output

# gradle, main 5adff78bb168, seed DEADBEEF (the same with -Ptests.asserts=false)
TestFirstDocValuesUpdateBufferedTwice > testFirstUpdateIsAccountedOnce FAILED
    java.lang.AssertionError: first=2162956 second=1048576
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:3046A8F0F574721E]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.index.TestFirstDocValuesUpdateBufferedTwice.testFirstUpdateIsAccountedOnce(TestFirstDocValuesUpdateBufferedTwice.java:45)
:lucene:core:test (FAILURE): 1 test, 1  failure

# standalone runner, the same message on main (-ea, -da), 9.12.3 (-da) and 10.6.0 (-da); passes with the fix shadowed

# supporting probe (not the repro), main 5adff78bb168, assertions off
ram: first=2162956 second=1048576 third=1048576
applied(precede=false): BD: applyDocValuesUpdates N msec for 1 segments, 1 field updates; 20 new updates
applied(precede=false): docs with the new value = 10 of 10
applied(precede=true): BD: applyDocValuesUpdates N msec for 1 segments, 2 field updates; 10 new updates
# with the fix: ram: first=1114340 second=1048616 third=1048576; 1 field updates; 10 new updates

Why it looks unintended

  • The buffer's own test creates it with the first update and adds only later ones; BufferedUpdates adds the first one again.
  • Lucene 7.6.0 stored each update once; the copy arrived with LUCENE-8590 in 7.7.0, and nothing explains it.
  • A reviewer called the same pattern a bug in a proposed vector method (#16214), and the PR's author fixed that method.
The full argument262 words
  • The class's invariant: the constructor records update 0 (numUpdates = 1, FieldUpdatesBuffer.java:49). TestFieldUpdatesBuffer.testBasics (TestFieldUpdatesBuffer.java:35-88), added in the same commit (as testBascis, renamed with the same body in 0650f995f9f), builds the buffer with update id:1, adds four more with addUpdate, and asserts the iterator returns exactly five entries, id:1 first and once.
  • The counter disagrees with the buffer: numFieldUpdates counts one per call (BufferedUpdates.java:143, :155), and InfoStream prints 1 field updates; 20 new updates for one update matching 10 documents.
  • History: 7.6.0 kept one LinkedHashMap<Term, NumericDocValuesUpdate> / <Term, BinaryDocValuesUpdate> per field, keyed by the update term (a repeated term replaced its entry), so each call was stored at most once, and it passes the RAM probe. b0e6a92ede3 (LUCENE-8590, "Optimize DocValues update datastructures", 2018-12-04; backport eaf840867fa in 7.7.0) added FieldUpdatesBuffer and the computeIfAbsent plus unconditional addUpdate pattern in one diff; no CHANGES entry, code comment or review comment on lucene-solr PR #513 mentions storing the first update twice.
  • Reviewers already call the pattern a bug: in the review of the unmerged PR #16214 (in-place vector updates), iprithv wrote on 2026-06-08 (https://github.com/apache/lucene/pull/16214#discussion_r3374995773) about the PR's new addVectorUpdate: "The first update for a field gets added twice ... Harmless due to last-write-wins but wastes memory and looks like a bug". The author fixed that method; its code comment there reads "otherwise the first update would be buffered twice". The review comment also said addNumericUpdate/addBinaryUpdate "have an if/else to avoid this"; they do not: their if/else only picks addUpdate or addNoValue.
The devil's-advocate check119 words

The case for intended: both classes are package-private; setRAMBufferSizeMB says "the account of RAM usage for pending deletions is only approximate" (LiveIndexWriterConfig.java:174); the copy is idempotent, so no documented result changes; and FieldUpdatesBuffer.java:50-54 says terms are stored "without de-duplication".

Why it still holds: the "approximate" note goes on to explain delete-by-Query, whose RAM Lucene cannot measure (:175-177); here a measured value is appended twice, so the bytes really are held twice. The no-de-duplication comment is about separate calls that repeat a term, not one call stored twice. And the copy is really applied, twice the per-document work, which also roughly halves the LFV-838 threshold. This supports calling the impact minor, not calling the code intended.

Suggested fix

Recommended Do not add the update the constructor already recorded (validated)

Create the field's buffer with the first update and do not add that update again; add later updates as now (+12/-8 lines in BufferedUpdates, +6/-4 in each method). With it the repro and 14 doc-values-update and soft-delete suites pass (155 tests), assertions on and off, and so do the core format, ECJ and forbidden-apis checks.

FieldUpdatesBuffer buffer = fieldUpdates.get(update.field);
if (buffer == null) {
  // the constructor records this update as the buffer's first entry: do not add it again
  fieldUpdates.put(
      update.field, new FieldUpdatesBuffer(fieldUpdatesBytesUsed, update, docIDUpto));
} else if (update.hasValue) {
  buffer.addUpdate(update.term, update.getValue(), docIDUpto);
} else {
  buffer.addNoValue(update.term, docIDUpto);
}
numFieldUpdates.incrementAndGet();
All options and trade-offs2 options · 355 words

One option was built and validated; the other is described, not run. The fix for LFV-838 is separate (in BinaryDocValuesFieldUpdates) and does not replace this one.

Do not add the update the constructor already recorded (validated) (recommended)

In addNumericUpdate and addBinaryUpdate: look the buffer up; if it is missing, create it with the update (the constructor records it) and stop there; otherwise call addUpdate/addNoValue as now. This is the shape the #16214 author used for the vector method.

FieldUpdatesBuffer buffer = fieldUpdates.get(update.field);
if (buffer == null) {
  // the constructor records this update as the buffer's first entry: do not add it again
  fieldUpdates.put(
      update.field, new FieldUpdatesBuffer(fieldUpdatesBytesUsed, update, docIDUpto));
} else if (update.hasValue) {
  buffer.addUpdate(update.term, update.getValue(), docIDUpto);
} else {
  buffer.addNoValue(update.term, docIDUpto);
}
numFieldUpdates.incrementAndGet();

Trade-offs

  • Changes only what is stored (one entry less per field per packet), not the order in which updates are applied, so results are unchanged.
  • fieldUpdates is a plain HashMap (BufferedUpdates.java:66), so get + put behaves like computeIfAbsent; the extra lookup happens once per field per packet.
  • The constructor already handles both hasValue cases (FieldUpdatesBuffer.java:80-83, :98-103, :110-112).

What the checks validated

  • Scratch worktree of 5adff78bb168, gradle, OpenJDK 25.0.3, seed DEADBEEF. Stock: the repro fails, assertions on and off.
  • With the fix: the repro plus TestFieldUpdatesBuffer, TestBufferedUpdates, TestFrozenBufferedUpdates, TestDocValuesFieldUpdates, TestBinaryDocValuesUpdates, TestNumericDocValuesUpdates, TestMixedDocValuesUpdates, TestSortedNumericDocValuesUpdates, TestIncrementalDocValuesUpdates, TestPendingSoftDeletes, TestSoftDeletesDirectoryReaderWrapper, TestSoftDeletesRetentionMergePolicy, TestAtomicUpdate and TestRollingUpdates: 155 tests, 0 failures, 2 skipped by assumptions, assertions on and off.
  • :lucene:core:checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass with the fix and the test.
  • The probe with the fix: the first 1 MiB update adds 1,114,340 bytes; one update matching 10 documents gives 10 new updates.

Make the constructor not record an update (not run)

Start every buffer empty and always call addUpdate/addNoValue.

Trade-offs

More invasive: the constructor uses the first update to seed fields[0], docsUpTo[0] and numericValues[0], which the one-field and one-value compaction relies on, and TestFieldUpdatesBuffer builds buffers that way, so it would need changes too.

Downstream note

Servers that apply their clients' binary doc-values updates inherit the doubled buffering: results stay correct, but LFV-838's writer close comes at roughly half the update size.

How it was found

Semi-formal: while checking the size limit of the related overflow bug LFV-838, a case that should have stayed under 2 GiB (2,000 documents x 1 MiB) still overflowed, which needs more than 2,047 MiB of copies. That pointed to two copies per document; a probe showed the buffer holding the first update twice, and the code confirmed it.

The method in full106 words
  • Arithmetic: one 1 MiB binary update matching 2,000 documents should append 2,000 MiB, below the 2,147,483,631-byte array limit, yet it overflowed at destination index 2^31, which needs 2,047 earlier 1 MiB appends: about two appends per document.
  • Probe: an executed same-package probe of the buffered-updates classes on the stock main jar showed the field buffer iterating the first update twice while counting it once.
  • Code and repro: the root cause was then read in BufferedUpdates and FieldUpdatesBuffer and reduced to the public-API test.
  • Reviews: an independent reproduction, reviews by a model from another family (OpenAI GPT-5.6), an intended-behaviour review and a final review; see Independent checks.

Prior reports, history and checks

Prior reports

No prior report of this defect (re-checked 2026-11-04). In an unmerged PR (#16214) a reviewer flagged the same pattern in a new vector-update method and wrongly said these two methods avoid it.

Searches5 searches

Nearest items, all about other defects: the #16214 review comment (https://github.com/apache/lucene/pull/16214#discussion_r3374995773; about the PR's own vector method, and it calls these two methods safe), #9636 (LUCENE-8590, which introduced the copy), #10268 (LUCENE-9228, the sort that hides the copy on the one-value path), #14261 (a nightly OOM through addNumericUpdate, with no analysis of the buffer), #16867 (delete-buffer overflow, merged 2026-10-30; changes flush control only), #11254 (per-term delete memory).

Caveat: keyword search only; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub apache/lucene issues, PRs and commits (incl. the migrated JIRA archive)2026-10-28automated keyword search, three query sets, plus manual searches and the LUCENE-8590 and LUCENE-9228 threadsthe #16214 review comment (a different method); nothing on these methods
All 411 open apache/lucene PRs2026-10-28changed-file lists checked for the update-buffer classesno PR changes these methods
Lucene mailing lists (dev@, java-user@, issues@, commits@), JIRA, downstream repositories, the web2026-10-28keyword searchnothing on this defect
Novelty re-check2026-11-03two independent passesnew; the related items are about a different defect
Upstream issues, PRs and main2026-11-04read-only GitHub API: issues/PRs naming FieldUpdatesBuffer or BufferedUpdates updated since 2026-10-27; commit history of both files; BufferedUpdates.java at upstream main aa366ae209anothing new; upstream main still has the code

History

In every release since 7.7.0: LUCENE-8590 (b0e6a92ede3, 2018) added the buffer and the double insert; 7.6.0 stored each update once.

Versions tested7 rows

b0e6a92ede3 (master) and its backport eaf840867fa (branch_7x), "LUCENE-8590: Optimize DocValues update datastructures" (Simon Willnauer, 2018-12-04; lucene-solr PR #513, issue now #9636). git tag --contains eaf840867fa includes 7.7.0, and 7.6.0 has no FieldUpdatesBuffer.java. Later edits to the two methods are cosmetic (2d6ad2fee6d reformat, ab453b9541a unnamed lambda parameter). Upstream main aa366ae209a (2026-11-04) still has the code; BufferedUpdates.java was last changed by #15048 (2025-12-10) and FieldUpdatesBuffer.java by #16581 (2026-09-21).

Releases 7.6.0 to 10.5.2 were run earlier with a plain-Java RAM probe (first / second 1 MiB update, bytes); 9.12.3, 10.6.0 and main with the one test for this doc.

Versions and branches executed, with results
Version or commitVerdictResult
7.6.0 not affectedprobe passes
7.7.0 first affectedprobe fails
7.7.3, 8.0.0 affectedprobe fails
8.11.4, 9.0.0, 10.0.0, 10.5.2 affectedprobe fails
9.12.3 affectedrepro fails
10.6.0 affectedrepro fails
main 5adff78bb168 affectedrepro fails

Independent checks

Seven checks; six say real, and a cross-model review, which could not run commands, returned only a procedural, low-confidence "not real". The strongest case for intended (internal classes, approximate RAM accounting, an idempotent copy) explains why the impact is minor, not why the code would be right. Confirmed, Low.

Checks and verdicts268 words

Every check ran code except the reviews by a model from another family, which could not run commands; for the second one, a separate run re-ran the repro.

Each independent check and its verdict
CheckVerdictWhat it found
Minimal repro re-run (this doc) fails on main, 9.12.3 and 10.6.0; passes with the fixAssertions on and off on main; gradle and standalone runner; fix validated in a scratch worktree (155 tests pass).
Independent reproduction realOwn tests: the field buffer iterates [id:a, id:a, id:b] while counting 2 updates; the first 4 MiB update adds 8,454,412 bytes against 4,194,304; 5 of 7 tests fail as expected on main, 10.5.2 and 9.12.3; its own fix passes them and 144 upstream tests.
First cross-model review, refute lens (OpenAI GPT-5.6) not real, low (procedural): could not run commandsIt could not run any command; its reading-only notes call the impact minor, which the doc accepts.
Cross-model review, intended lens (OpenAI GPT-5.6) real, medium confidenceRead-only; rated the works-as-designed case moderate and rejected it; found the #16214 comment. A separate run re-ran the repro: the RAM test fails, 1,100 x 1 MiB closes the writer, the control commits.
Intended-behaviour review real, high confidenceOwn InfoStream test: 20 instead of 10 applications (binary first; numeric with two values), controls 10, final values correct; rebutted six intended-behaviour arguments.
Final review confirmed, high confidence; impact minorRe-ran the independent tests and the RAM test; no wrong results, no security impact.
Re-runs on newer main still reproducesmain e1faeba08bd (2026-11-02) with assertions on and off: same failure and numbers.

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

Finding record

LFV-837, snapshot of 2026-11-06

Registry title
IndexWriter buffers the first doc-values update of each field twice in every buffered-updates packet (BufferedUpdates adds again the update that FieldUpdatesBuffer's constructor already recorded), so that update is RAM-accounted twice and, except on the all-same-value numeric path, applied twice to every matching document (since 7.7.0, LUCENE-8590)
Area
index / buffered doc-values updates (BufferedUpdates, FieldUpdatesBuffer, FrozenBufferedUpdates)
Priority
Low, rank 995: On default config the first doc-values update of each field is buffered twice in every packet, but final values stay correct and the cost is only extra RAM accounting and apply work.
Impact class
minor
Impact note
Any application that updates doc values (updateNumericDocValue, updateBinaryDocValue, updateDocValues, softUpdateDocument(s)) with a default configuration has the first update of each field in every buffered-updates packet (the global delete queue's and each per-thread writer's) stored and counted twice in ramBytesUsed() and, for binary updates or numeric updates with differing values, resolved and written again to every matching document; final doc values stay correct, so the cost is one extra copy of that update's term and value (a 1 MiB binary update counts 2,162,956 bytes instead of about 1,048,576, so RAM-triggered flushes come earlier), duplicate apply work, and, through the linked capacity defect of BinaryDocValuesFieldUpdates (LFV-838, which has its own fix site), a writer close at half the buffered binary-update size (1,100 documents x 1 MiB); no scale is needed for the duplicate itself.
Summary
You update a doc-values field through IndexWriter (for example updateBinaryDocValue with a 1 MiB value) and expect each update to be buffered once, as every later update of the same field is. Instead BufferedUpdates creates the field's FieldUpdatesBuffer with the update, whose constructor already records it as entry 0, and then adds the same update again, so the first 1 MiB update adds 2,162,956 bytes to ramBytesUsed() while each later one adds 1,048,576, and IndexWriter's InfoStream reports '1 field updates; 20 new updates' for one binary update matching 10 documents. Final values stay correct because the duplicate is identical and adjacent; Lucene 7.6.0 buffered the update once, and the double insert arrived with LUCENE-8590 in 7.7.0.
Introduced
b0e6a92ede321dc6932348978bed1a4c04a70d8c (master) and its branch_7x backport eaf840867fade1dfdd1adb9bf510ec181aa4e3f8 (the commit that shipped in 7.7.0), 'LUCENE-8590: Optimize DocValues update datastructures' (apache/lucene-solr#513 by s1monw, opened 2018-12-04, merged 2018-12-05; issue now apache/lucene#9636) [read: the same diff added FieldUpdatesBuffer, whose constructor records the initial update (numUpdates = 1), and rewrote BufferedUpdates.addNumericUpdate/addBinaryUpdate as computeIfAbsent(new FieldUpdatesBuffer(..., update, ...)) followed by an unconditional addUpdate/addNoValue of the same update; its own TestFieldUpdatesBuffer calls addUpdate only for later updates; git log -L shows only cosmetic later edits to the two methods (2d6ad2fee6d LUCENE-9570 reformat, ab453b9541a #14308 unnamed lambda parameter)] [exec: release boundary 7.6.0 pass, 7.7.0 fail; tag ancestry: 7.7.0 contains eaf840867fa, 7.6.0 contains neither commit and has no FieldUpdatesBuffer.java; executed per release, not per commit] (2018-12-04 (author date of both commits; committed 2018-12-05 on master and branch_7x))
First affected version
7.7.0 [exec] (first master-line release 8.0.0 [exec]); 7.6.0 is unaffected [exec]; it and earlier releases [read] buffered each update once in a per-field LinkedHashMap keyed by term
Affected versions
7.7.0 through 10.6.0 and main (a9779ef3c193 and 5adff78bb168) [exec: the finder's public-API RAM probe fails on 7.7.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main, and its applied-count probe on 7.7.0, 8.0.0, 9.12.3, 10.5.2 and main; the finder's JUnit fails on main (-ea, -da), 10.5.2, 9.12.3, 8.11.4, 8.0.0 and 7.7.3; the independent reproduction's tests on main (-ea, -da), 10.5.2 and 9.12.3; the intended-behaviour check's on main (-ea, -da) and 10.5.2 (-da); the doc's one-test repro TestFirstDocValuesUpdateBufferedTwice on main 5adff78bb168 (-ea, -da), 10.6.0 and 9.12.3 (-da, release jars); 7.6.0 passes]; the other 7.7-10.5 releases by tag ancestry plus cosmetic-only line history [read]; not fixed on upstream main 20ced71c1e6e (2026-10-27) [read, GitHub contents API 2026-10-28], nor on branch_10x and branch_10_6 [read]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal
Upstream and prior reports
none found that reports or fixes this defect; one related item (searched 2026-10-28: three scripted prior-report searches with 63 queries each over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and JIRA; 15 groups of manual GitHub searches, git history, the LUCENE-8590 and LUCENE-9228 reviews, JIRA and the lists; the changed files of all 411 open apache/lucene PRs; plus the finder's 51-query and the intended-behaviour check's 39-query runs; re-checked the same day: the changed files of all 59 PRs created or updated since 2026-10-26 touch none of BufferedUpdates, FieldUpdatesBuffer, FrozenBufferedUpdates, *DocValuesFieldUpdates or DocumentsWriterDeleteQueue, and upstream main 20ced71c1e6e still has the pattern). The overlap: in the review of the closed, unmerged PR apache/lucene#16214 (KNN vector updates, by LantaoJin), iprithv commented on 2026-06-08 (https://github.com/apache/lucene/pull/16214#discussion_r3374995773) on the PR's new addVectorUpdate: 'The first update for a field gets added twice. computeIfAbsent creates the buffer with the initial value already stored, then the next line unconditionally calls buffer.addUpdate() again ... Compare with addNumericUpdate/addBinaryUpdate which have an if/else to avoid this'; the author fixed only the new method. That comment wrongly declares addNumericUpdate/addBinaryUpdate safe: at the reviewed commit they are byte-identical to main, and their if/else only chooses between addUpdate and addNoValue. Nearest other items, not this bug: #9636 (LUCENE-8590, introducing), #10268 (LUCENE-9228, the sort that hides the duplicate on the single-value path), #9639, #9644, #14261 (nightly OOM in a stress test), #11254 (LUCENE-10217, per-term delete memory), #16875 (javadoc typos).
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT), assertions on and off, with gradle and with a standalone runner; the one test on the 9.12.3 and 10.6.0 release jars; the fix in a scratch worktree of 5adff78bb168. Earlier runs on main a9779ef3c193 and on the releases 7.6.0 to 10.5.2, and a re-run on main e1faeba08bd. Upstream main aa366ae209a (2026-11-04) still has the code.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/BufferedUpdates.java:134-156 (at a9779ef3c193) (addNumericUpdate/addBinaryUpdate: computeIfAbsent(update.field, _ -> new FieldUpdatesBuffer(fieldUpdatesBytesUsed, update, docIDUpto)), then unconditionally buffer.addUpdate(update.term, update.getValue(), docIDUpto) or buffer.addNoValue(update.term, docIDUpto), then numFieldUpdates.incrementAndGet())
  • lucene/core/src/java/org/apache/lucene/index/FieldUpdatesBuffer.java:49 (at a9779ef3c193) (numUpdates = 1), :71-113 (constructors append the initial term, numeric value or binary value), :182-217 (addUpdate/addNoValue append again; append returns numUpdates++, so the duplicate gets ord 1), :219-231 (finish: only a single-value numeric buffer with values and one term field sorts its terms, and that iterator skips equal adjacent terms)
  • lucene/core/src/java/org/apache/lucene/index/FrozenBufferedUpdates.java:214 (at a9779ef3c193) (InfoStream 'applyDocValuesUpdates ... %d field updates; %d new updates'), DocumentsWriterDeleteQueue.java:507-510 (public updates are routed to addNumericUpdate/addBinaryUpdate)
  • lucene/core/src/test/org/apache/lucene/index/TestFieldUpdatesBuffer.java:35-45 (at a9779ef3c193) (the buffer is built with the first update and addUpdate is called only for later ones)
  • lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:157-180 (at a9779ef3c193) (setRAMBufferSizeMB: flush when buffered documents and deletions use this much RAM; 'the account of RAM usage for pending deletions is only approximate', about delete-by-Query)
  • SECURITY.md sections 'Scope and Threat Model', 'Untrusted query and analysis input', 'In-scope reports'
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:500-511 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/BufferedUpdates.java:134-156 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/FieldUpdatesBuffer.java:49, (at 5adff78bb168) :76, :99, :111, :182-217, :224, :268-271, :370-388
  • lucene/core/src/java/org/apache/lucene/index/FrozenBufferedUpdates.java:107, (at 5adff78bb168) :214-218, :246-348
  • lucene/core/src/java/org/apache/lucene/index/BinaryDocValuesFieldUpdates.java:94; (at 5adff78bb168) DocValuesFieldUpdates.java:441-448
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1664, (at 5adff78bb168) :1721, :1938, :1962, :1996, :2037, :2070; FieldUpdatesBuffer.java:135-180, :189
Why it looks unintended
  • lucene/core/src/test/org/apache/lucene/index/TestFieldUpdatesBuffer.java:35-88 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/FieldUpdatesBuffer.java:49, (at 5adff78bb168) :50-54; BufferedUpdates.java:143, :155; LiveIndexWriterConfig.java:174-177
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/BufferedUpdates.java:66; (at 5adff78bb168) FieldUpdatesBuffer.java:80-83, :98-103, :110-112
How it was found
  • lucene/core/src/java/org/apache/lucene/index/BufferedUpdates.java:134-156; (at 5adff78bb168) FieldUpdatesBuffer.java:49

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 5adff78bb168 and a probe's output.

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.