One update is stored twice when it creates its field's buffer.
updateNumericDocValue,updateBinaryDocValueandupdateDocValuesdo 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.- 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.
- Buffered updates count toward the writer's RAM buffer (
ramBytesUsed(); 16 MB by default), which decides when the writer flushes.
addUpdate; later updates go in once. Select the still to open it at full size. Generated with AI, checked against the code.- You add a document with a binary doc-values field
payload, commit, then callupdateBinaryDocValuetwice onpayloadwith 1 MiB values (terms that match no document) and readramBytesUsed()after each call. - You expect each call to add about 1 MiB (1,048,576 bytes): an update waits in memory, once, until the writer applies it.
- 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.
- 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. - 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).
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.
- Public updates reach the buffer through
DocumentsWriterDeleteQueue.java:500-511: NUMERIC and SORTED_NUMERIC go toBufferedUpdates.addNumericUpdate(:507), BINARY toaddBinaryUpdate(:510). BufferedUpdates.java:146-156(addNumericUpdate,:134-144, is the same) creates a missing buffer withfieldUpdates.computeIfAbsent(update.field, _ -> new FieldUpdatesBuffer(fieldUpdatesBytesUsed, update, docIDUpto))and then always callsbuffer.addUpdate(update.term, update.getValue(), docIDUpto)(:151) orbuffer.addNoValue(update.term, docIDUpto)(:153);numFieldUpdates.incrementAndGet()(:155) counts the call once.- The constructor already records the update:
FieldUpdatesBuffer.java:49private int numUpdates = 1;,:76appends the term,:99stores the numeric value,:111appends the binary value.addUpdate(:182-200numeric,:207-212binary, value appended at:210) andaddNoValue(:202-205) callappend(:214-217), which appends the term again and returnsnumUpdates++: the copy is entry 1.
- RAM: the term bytes (and the value bytes, when the update has a value) are appended twice, so they count twice in
ramBytesUsed(). - Apply:
FrozenBufferedUpdateslogsnumFieldUpdatesas "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 intoBinaryDocValuesFieldUpdates.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. - 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.
| observable | main | with the fix |
|---|---|---|
ramBytesUsed() delta of three 1 MiB binary updates of one field | 2,162,956 / 1,048,576 / 1,048,576 | 1,114,340 / 1,048,616 / 1,048,576 |
| InfoStream, one binary update matching 10 documents | 1 field updates; 20 new updates | 1 field updates; 10 new updates |
| the same after a non-matching update of the field | 2 field updates; 10 new updates | 2 field updates; 10 new updates |
| documents with the new value | 10 of 10 | 10 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) andsoftUpdateDocument(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 throughBufferedUpdatesand is not affected. - Where it costs something: large binary values. The copy counts in the RAM buffer, so the writer flushes sooner than
setRAMBufferSizeMBimplies, 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
docIDUptoare 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.
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.
/*
* 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);
}
}
}
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.
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.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT): fails; 9.12.3 and 10.6.0: fail; with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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
# 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- The buffer's own test creates it with the first update and adds only later ones;
BufferedUpdatesadds 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 (astestBascis, renamed with the same body in0650f995f9f), builds the buffer with updateid:1, adds four more withaddUpdate, and asserts the iterator returns exactly five entries,id:1first and once. - The counter disagrees with the buffer:
numFieldUpdatescounts one per call (BufferedUpdates.java:143,:155), and InfoStream prints1 field updates; 20 new updatesfor 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; backporteaf840867fain 7.7.0) addedFieldUpdatesBufferand thecomputeIfAbsentplus unconditionaladdUpdatepattern 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 saidaddNumericUpdate/addBinaryUpdate"have an if/else to avoid this"; they do not: their if/else only picksaddUpdateoraddNoValue.
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.
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.
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.
fieldUpdatesis a plainHashMap(BufferedUpdates.java:66), soget+putbehaves likecomputeIfAbsent; the extra lookup happens once per field per packet.- The constructor already handles both
hasValuecases (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,TestAtomicUpdateandTestRollingUpdates: 155 tests, 0 failures, 2 skipped by assumptions, assertions on and off. :lucene:core:checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestpass 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.
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.
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.
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
BufferedUpdatesandFieldUpdatesBufferand 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub apache/lucene issues, PRs and commits (incl. the migrated JIRA archive) | 2026-10-28 | automated keyword search, three query sets, plus manual searches and the LUCENE-8590 and LUCENE-9228 threads | the #16214 review comment (a different method); nothing on these methods |
| All 411 open apache/lucene PRs | 2026-10-28 | changed-file lists checked for the update-buffer classes | no PR changes these methods |
| Lucene mailing lists (dev@, java-user@, issues@, commits@), JIRA, downstream repositories, the web | 2026-10-28 | keyword search | nothing on this defect |
| Novelty re-check | 2026-11-03 | two independent passes | new; the related items are about a different defect |
| Upstream issues, PRs and main | 2026-11-04 | read-only GitHub API: issues/PRs naming FieldUpdatesBuffer or BufferedUpdates updated since 2026-10-27; commit history of both files; BufferedUpdates.java at upstream main aa366ae209a | nothing new; upstream main still has the code |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 7.6.0 | not affected | probe passes |
| 7.7.0 | first affected | probe fails |
| 7.7.3, 8.0.0 | affected | probe fails |
| 8.11.4, 9.0.0, 10.0.0, 10.5.2 | affected | probe fails |
| 9.12.3 | affected | repro fails |
| 10.6.0 | affected | repro fails |
| main 5adff78bb168 | affected | repro fails |
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.
| Check | Verdict | What it found |
|---|---|---|
| Minimal repro re-run (this doc) | fails on main, 9.12.3 and 10.6.0; passes with the fix | Assertions on and off on main; gradle and standalone runner; fix validated in a scratch worktree (155 tests pass). |
| Independent reproduction | real | Own 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 commands | It 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 confidence | Read-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 confidence | Own 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 minor | Re-ran the independent tests and the RAM test; no wrong results, no security impact. |
| Re-runs on newer main | still reproduces | main e1faeba08bd (2026-11-02) with assertions on and off: same failure and numbers. |
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 423 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,302 words
- 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 of5adff78bb168. Earlier runs on maina9779ef3c193and on the releases 7.6.0 to 10.5.2, and a re-run on maine1faeba08bd. Upstream mainaa366ae209a(2026-11-04) still has the code.
- 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-388lucene/core/src/java/org/apache/lucene/index/FrozenBufferedUpdates.java:107, (at 5adff78bb168) :214-218, :246-348lucene/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
- TestFirstDocValuesUpdateBufferedTwice.java (one test class):
repro/LFV-837/TestFirstDocValuesUpdateBufferedTwice.java(48 lines)
Made in parallel with this doc from the code at 5adff78bb168 and a probe's output.
assets/LFV-837/bug.png(423 KB)
