All findings · Lucene finding · LFV-643 · binary-dv-update-retains-caller-bytesref

IndexWriter.updateBinaryDocValue keeps the caller's BytesRef, so documents still buffered in RAM get bytes written after the call

IndexWriter.updateBinaryDocValue keeps the BytesRef you pass instead of copying it, so a document still in RAM gets whatever bytes it holds when the writer applies the update later: a loop over one BytesRefBuilder stores new2, new2, new2 instead of new0, new1, new2. Silent, committed, since 4.8.0.

Summary

Priority
Medium, rank 17The core IndexWriter binary doc-values update silently commits the wrong value for buffered documents on default config whenever the caller reuses its BytesRef, Lucene's own scratch-buffer idiom, and a one-line copy fixes it.
Impact
silent wrong results
Since
4.8.0 (LUCENE-5513, 2014)introduced 2014-03-18 · over 12 years
Affected
4.8.0 to 10.5.2 and main
Status
documented
Found by
code reading, confirmed by a differential JUnit

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Binary doc-values updates: what you pass is not always what a document gets. It depends on whether the target document has already been flushed.

Results of the executed diagram probe on main: updateBinaryDocValue with the value b reaches a flushed and a buffered document; the caller then writes c into its BytesRef; after commit the flushed document reads b and the buffered document reads c. If the writer indexes one more document first, both read b. A BytesRefBuilder loop over doc0, doc1, doc2 stores new2, new2, new2; passing another index's binaryValue() stores src2, src2, src2. CheckIndex reports the index clean.
One update value reaches a flushed and a buffered document; the caller then reuses its BytesRef. The flushed document keeps the value of the call, the buffered one gets the later bytes. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index document flushed with binary doc value a and call flush(); index document buffered with a, which stays in the writer's RAM buffer.
  2. Set both to b with updateBinaryDocValue, passing one BytesRef value = new BytesRef("b"), then reuse that BytesRef: set its byte to c and commit(). You expect both documents to read b.
  3. You get flushed=b but buffered=c. The writer kept your BytesRef and read it for the buffered document only at the flush of the commit.
  4. The same with Lucene's usual idioms: updating doc0, doc1, doc2 to new0, new1, new2 through one BytesRefBuilder stores new2 three times; copying src0, src1, src2 from another index by passing its binaryValue() straight in stores src2 three times, with no change by the caller at all.
  5. No exception, CheckIndex is clean, and the wrong values are committed. Default IndexWriterConfig and codec, one thread; every release from 4.8.0 to 10.5.2 and main.

Who is affected

Applications that call updateBinaryDocValue, or updateDocValues with a BinaryDocValuesField, and then reuse the BytesRef (a BytesRefBuilder scratch, a reused byte[]) or pass a reader's binaryValue() straight in. Default config and codec, one thread, no scale. Only documents added since the last flush or refresh are hit. The API itself is rarely used.

Mechanism, terms and numbers533 words

IndexWriter.updateBinaryDocValue(Term, String, BytesRef), and updateDocValues(Term, Field...) with a BinaryDocValuesField, store the caller's BytesRef object in the queued update without copying it. Documents in flushed segments get the bytes as of the call, because the writer's global buffer copies them inside the call. Documents still buffered in RAM get the bytes that BytesRef holds when the per-thread writer holding them next applies the queued updates: at the end of its next indexing operation, or when it is flushed (commit, refresh). So a caller that reuses its buffer after the call, or passes a reader's BinaryDocValues.binaryValue() straight through, silently commits the wrong value on buffered documents: a loop over one BytesRefBuilder gives every buffered target the last value. Default config and codec, one thread (every run), deterministic, CheckIndex clean; every release since 4.8.0 (2014). Binary doc-values updates are a rarely used API.

Terms

  • Binary doc-values update: IndexWriter.updateBinaryDocValue(term, field, value) replaces the BinaryDocValues value of every document that matches term, without reindexing the document. updateDocValues(term, fields...) does the same for one or more Fields.
  • BytesRef: a view of a byte array (bytes, offset, length). Lucene reuses BytesRef objects a lot: BytesRefBuilder.get() returns the same object after every copyChars, and a doc-values iterator refills one object for every document.
  • Flushed vs buffered: a document that IndexWriter has written to a segment is flushed. A document added since the last flush (commit, refresh, or RAM-triggered flush) is buffered in the RAM buffer of a per-thread writer.

The bug

  1. updateBinaryDocValue passes the caller's object on: new BinaryDocValuesUpdate(term, field, value) (IndexWriter.java:2013). updateDocValues(Term, Field...) does the same with f.binaryValue() (:2113).
  2. The update stores the reference: this.value = value; (DocValuesUpdate.java:114).
  3. The update is queued in the writer's delete queue (DocumentsWriterDeleteQueue.java:135-139). During the call only the global buffer reads it (tryApplyGlobalSlice, :198-215, when its tryLock succeeds; in every executed run the flushed documents got the call-time value, as that gives): that copies the bytes (FieldUpdatesBuffer.java:210, byteValues.append(value), or the constructor at :107-112 for a field's first update in that buffer), and the global buffer is what is applied to flushed segments.
  4. The per-thread writer that holds the buffered documents reads the queued update only at the end of its next indexing operation (DocumentsWriterPerThread.java:361-390) or at flush (:427-439). Until then the queue holds the caller's BytesRef, so the copy for buffered documents takes whatever bytes it holds then. At flush the global packet (b) is published before the new segment, which gets a later delete generation, so it does not overwrite the new segment's own copy (IndexWriter.java:2969-2990, :6783).

The numbers

One update with b to a flushed and a buffered document, then the caller writes c: flushed=b, buffered=c. A BytesRefBuilder loop over three buffered documents: new2, new2, new2. A pass-through of another index's binaryValue(): src2, src2, src2. updateDocValues with one reused BinaryDocValuesField: x2, x2. A fresh BytesRef per call: correct. If the writer indexes one more document before the caller changes the bytes, the value is correct too; that document closes the window.

Exposure and likelihood345 words
  • Who is hit. Callers of updateBinaryDocValue, or of updateDocValues with a BinaryDocValuesField, that change or reuse the BytesRef after the call: one BytesRefBuilder for a loop of updates, a reused byte[] wrapped in a fresh BytesRef per call (it fails the same way), or a reader-owned BytesRef such as BinaryDocValues.binaryValue() passed straight through. Callers whose BytesRef points at bytes they never change afterwards, such as a fresh BytesRef over a fresh array per call as Lucene's own tests do, never see it.
  • Which documents. Targets added since the last flush or refresh that are still in a per-thread writer's RAM buffer, for updates made since that per-thread writer's last indexing operation. Which documents those are depends on flush timing (RAM buffer, refreshes), so the same code can store right values for some documents and wrong ones for others.
  • Default, not opt-in. An unmodified new IndexWriterConfig() and the default codec give the same wrong values for the changed-after-return, loop and pass-through cases (on a ByteBuffersDirectory and an FSDirectory), one thread; deterministic, the same with assertions on and off. The JUnit itself sets NoMergePolicy and turns off flushing by document count. No scale: 1 to 3 documents.
  • Silent and persistent. No exception; the wrong value is committed, CheckIndex reports the index clean wherever it was run (the changed-after-return, loop and pass-through cases), and NRT readers see it too.
  • Reach. Binary doc-values updates are rarely used: no Lucene module calls updateBinaryDocValue outside tests, and a read-only code search of three common downstream search servers found it only in one test (code search can miss call sites).
  • Age. Every release since 4.8.0 (2014). Until 7.6.0 even flushed documents got the late bytes.
  • Not claimed (code reading only, not executed). With several indexing threads, each per-thread writer applies the update at its own next indexing operation; the global slice can miss its tryLock, in which case flushed segments could read late bytes on 7.7.0 and later too; and a binary field passed to softUpdateDocument(s) goes into the same kept update, which other per-thread writers apply later.

Minimal repro

The test shows that IndexWriter.updateBinaryDocValue keeps the caller's BytesRef, so a buffered document updated through a reused BytesRefBuilder gets the scratch's later bytes instead of the value passed to its call.

TestBinaryDocValuesUpdateReusedBytesRef.java (JUnit test)54 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.document.Field;
import org.apache.lucene.document.StringField;
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;
import org.apache.lucene.util.BytesRefBuilder;

public class TestBinaryDocValuesUpdateReusedBytesRef extends LuceneTestCase {

  public void testReusedBytesRefBuilder() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter writer = new IndexWriter(dir, new IndexWriterConfig())) {
      for (String id : new String[] {"0", "1"}) {
        Document doc = new Document();
        doc.add(new StringField("id", id, Field.Store.NO));
        doc.add(new BinaryDocValuesField("bdv", new BytesRef("old")));
        writer.addDocument(doc);
      }
      BytesRefBuilder scratch = new BytesRefBuilder();
      scratch.copyChars("new0");
      writer.updateBinaryDocValue(new Term("id", "0"), "bdv", scratch.get());
      scratch.copyChars("new1");
      writer.updateBinaryDocValue(new Term("id", "1"), "bdv", scratch.get());
      writer.commit();
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        BinaryDocValues values = getOnlyLeafReader(reader).getBinaryDocValues("bdv");
        assertTrue(values.advanceExact(0));
        // expected doc 0 to get the value passed with its update; actual: the scratch's later "new1"
        assertEquals("new0", values.binaryValue().utf8ToString());
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-643/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.index.TestBinaryDocValuesUpdateReusedBytesRef
Expected vs actual
Expected doc 0 to hold new0 after commit; actual: new1 (expected:<new[0]> but was:<new[1]>), with -ea and -da.

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

More files, environment, full output32 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => org.junit.ComparisonFailure: expected:<new[0]> but was:<new[1]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.index.TestBinaryDocValuesUpdateReusedBytesRef.testReusedBytesRefBuilder(TestBinaryDocValuesUpdateReusedBytesRef.java:50)

Why it looks unintended

  • The javadoc promises "the given value" and never says the BytesRef is kept; addDocument, tryUpdateDocValue and Term copy during the call.
  • One call stores b in a flushed document and c in a buffered one, yet flushing "just moves the internal buffered state ... into the index".
  • A reader's binaryValue() passed straight in, with no caller change, is enough.
The full argument278 words
  • The contract. "Updates a document's BinaryDocValues for field to the given value", @param value "new value for the field" (IndexWriter.java:1981-1995); updateDocValues: "Updates documents' DocValues fields to the given values" (:2060). Neither says the BytesRef is kept after the call.
  • Flushing must not change the result. The IndexWriter class javadoc: "flushing just moves the internal buffered state in IndexWriter into the index" (IndexWriter.java:131-132). Here the stored value depends on whether the target had been flushed.
  • The writer copies the other BytesRefs it takes during the call. addDocument consumes a BinaryDocValuesField's bytes before returning (a checker's control: one field over a builder changed between calls gives each document its own value); tryUpdateDocValue copies into BinaryDocValuesFieldUpdates (IndexWriter.java:1759-1761, BinaryDocValuesFieldUpdates.java:94-98); Term(String, BytesRef): "The provided BytesRef is copied when it is non null" (Term.java:50-55). The update's own code calls the value "a final value" (DocValuesUpdate.java:119).
  • Precedent. Until 2015 Term(String, BytesRef) carried "WARNING: the provided BytesRef is not copied, but used directly". LUCENE-6821 ("TermQuery's constructors should clone the incoming term", #7879, commit 9534904c250) made Term copy, because term queries built from shared BytesRef objects are "a bit trappy"; mikemccand: "I consider this a bug fix". That trap was even documented; this one is not.
  • Lucene copies reader-owned values before keeping them. The default bulk implementation in the BinaryDocValues javadoc stores BytesRef.deepCopyOf(binaryValue()) (BinaryDocValues.java:43-50). Here the object that keeps the value is IndexWriter's internal update.
  • Tests and history. Upstream tests allocate a fresh BytesRef per call (TestBinaryDocValuesUpdates.toBytes, :69-83); no test asserts late-read behaviour. LUCENE-5513 (#6576), which added the API, never discusses copying.
The devil's-advocate check429 words

The case for "intended". Lucene is zero-copy by habit. Field's constructor and setBytesValue say "NOTE: the provided BytesRef is not copied so be sure not to change it until you're done with this field" (Field.java:190-191, 332-333), and in LUCENE-6321 (#7382, won't fix, 2015) rmuir called modifying term bytes after construction "an abuse case". So: do not change a BytesRef you handed to Lucene.

Three more texts a maintainer may cite. The BytesRefBuilder.get() javadoc: "Any update to the content of this builder might invalidate the provided ref and vice-versa" (BytesRefBuilder.java:151-154), against the loop. The 4.9.0 CHANGES entry of LUCENE-5703: "BinaryDocValues API changed to work like TermsEnum and not allocate/copy bytes on each access, you are responsible for cloning if you want to keep data around" (lucene/CHANGES.txt:12171-12173), against the pass-through. And shaie on LUCENE-5189 ("Numeric DocValues Updates", 2013-11-21): "The updates are buffered in their raw form in memory until a flush is needed (e.g. commit(), or NRT-open)."

Why it fails. updateBinaryDocValue(Term, String, BytesRef) takes no Field, and neither it nor BinaryDocValuesField's constructor carries such a note. The NOTE does not say when the writer is "done"; for addDocument it is done when the call returns. The pass-through case changes nothing the caller owns. No reading of the NOTE explains why the stored value depends on whether the target was flushed. The same LUCENE-6321 comment goes on: "But maybe its a good idea to do a little "extra" clone work to make these core queries defensive." The builder javadoc says the ref follows the builder, not how long IndexWriter reads it, and Term(String, BytesRefBuilder) copies (Term.java:63-66). In the pass-through the caller keeps nothing: IndexWriter keeps the reader's object out of the caller's sight, while the BinaryDocValues javadoc's own example clones before keeping. The 2013 comment, from when only numeric updates existed, contrasts updates buffered "raw" (term and value) with their resolved per-document form; it does not say the caller's objects are kept.

Where it partly survives. The reused-Field case (one BinaryDocValuesField whose BytesRef the caller changes between two updateDocValues calls) is close to what the NOTE warns about, so it is the secondary case. The documented reuse idiom, setBytesValue(new BytesRef(...)) per call, is not affected. OpenAI GPT-5.6 reviews rated the "intended" argument moderate and still judged the bug real, and the GPT-5.6 fact check of this doc rated it moderate for the direct API (weaker for the pass-through) and strong for the reused-Field case; the devil's-advocate checker and the adjudication found that it fails for the direct API.

Suggested fix

Recommended Copy the value in the BinaryDocValuesUpdate constructor (validated)

Copy once at the boundary: BytesRef.deepCopyOf(value) in the BinaryDocValuesUpdate(Term, String, BytesRef) constructor, which both entry points use (one line). Cost: one array copy per binary update value. With it the repro passes 5/5, and the whole org.apache.lucene.index test package (1,400 tests) and the backward-compatibility update test pass.

BinaryDocValuesUpdate(Term term, String field, BytesRef value) {
  // Copy the caller's bytes, like Term(String, BytesRef) does: documents buffered in RAM get
  // this update only after the IndexWriter call has returned.
  this(term, field, value == null ? null : BytesRef.deepCopyOf(value), BufferedUpdates.MAX_INT);
}
All options and trade-offs3 options ยท 535 words

Option A was validated in a scratch worktree of main a9779ef3c193 through gradle; options B and C are design alternatives that were not run.

Copy the value in the BinaryDocValuesUpdate constructor (validated) (recommended)

One line in DocValuesUpdate.java: the package-private constructor that both IndexWriter entry points call (IndexWriter.java:2013, :2113) deep-copies a non-null value. prepareForApply keeps using the private constructor, so there is no second copy. This is the remedy upstream chose for Term(String, BytesRef) in LUCENE-6821.

BinaryDocValuesUpdate(Term term, String field, BytesRef value) {
  // Copy the caller's bytes, like Term(String, BytesRef) does: documents buffered in RAM get
  // this update only after the IndexWriter call has returned.
  this(term, field, value == null ? null : BytesRef.deepCopyOf(value), BufferedUpdates.MAX_INT);
}

Trade-offs

  • Cost: one array copy per binary update value (an updateDocValues call with several binary fields makes one per field), next to the copy FieldUpdatesBuffer already makes for every buffer the update is applied to.
  • Reach: the constructor is also used, through buildDocValuesUpdate, by tryUpdateDocValue (IndexWriter.java:1724), which copies the bytes inside the call anyway (:1759-1761), so there the copy is extra but harmless; and by softUpdateDocument(s) (:1674, :1947) with a binary field, where it is needed: the calling per-thread writer applies the update inside the call, but other per-thread writers apply the queued node later (code reading, not executed).
  • null: the null branch is reached: updateDocValues(term, new BinaryDocValuesField(field, null)) resets a value (TestMixedDocValuesUpdates.testResetValue, :628), and that test passes with the fix.
  • RAM accounting: unchanged. DocValuesUpdate.sizeInBytes() and valueSizeInBytes() have had no caller since LUCENE-8590 (b0e6a92ede32) removed the last one.

What the checks validated

  • The doc's test: 5/5 pass with assertions on and off (gradle, scratch worktree of main a9779ef3c193, seed DEADBEEF); stock main fails 4 of 5.
  • TestBinaryDocValuesUpdates, TestNumericDocValuesUpdates, TestSortedNumericDocValuesUpdates, TestMixedDocValuesUpdates, TestIncrementalDocValuesUpdates and the new test (116 tests with two other methods that match the filter), plus TestDVUpdateBackwardsCompatibility in backward-codecs (12): 0 failures, 3 skipped by assumptions, assertions on and off.
  • The whole org.apache.lucene.index test package with assertions on: 1,400 tests, 0 failures (56 skipped).
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApis and renderJavadoc for core pass.
  • Earlier, the same code change shadowed ahead of main's jars made every checker's own test pass: the finder's 3, the reviewer's 8, the reproducer's 6, the works-as-designed reviewer's 4, the devil's advocate's 5 and (run by the fact check) the audit's 4.

Copy at the two IndexWriter call sites

BytesRef.deepCopyOf at IndexWriter.java:2013 and, for the updateDocValues path, at :2113.

Trade-offs

Same effect; keeps BinaryDocValuesUpdate a plain holder, at the price of two sites to keep in sync. It saves no copy over option A: a copy at :2113 runs inside buildDocValuesUpdate, which tryUpdateDocValue and softUpdateDocument(s) share. Not run.

Javadoc only

Document on updateBinaryDocValue, updateDocValues and BinaryDocValuesField that the BytesRef must not change after the call.

Trade-offs

No cost, but the honest window (until the per-thread writer holding the target documents next indexes a document or is flushed) is something a caller cannot see or control, so in practice the note says "never reuse it". It keeps the flushed-versus-buffered inconsistency and the reader pass-through trap. Not run.

Downstream note

Downstream relevance: an application that keeps per-document data in binary doc values and updates it through a reused buffer silently stores one document's value on other documents; a read-only code search found no common search server that calls this API outside tests.

How it was found

Code reading, then a test; no model or proof. The hunt plan for IndexWriter update-value ownership listed the lead, and an AI tool confirmed it with a JUnit that changes the caller's BytesRef after one update reached a flushed and a buffered document.

The method in full210 words
  • The lead. The hunt plan for the hunt unit on IndexWriter update-value ownership and doc-values buffers listed it from reading main: a BinaryDocValuesUpdate keeps the caller's BytesRef, and a per-thread writer copies the bytes only at its next document or at flush. Not executed at that point.
  • The witness. The finder (an AI tool) wrote a differential "mutate after return" JUnit: one update applied to a flushed and to a buffered document, then the caller changes its own BytesRef. It then classified every IndexWriter entry point that takes a caller-owned mutable object, by executed probes except for the ColumnBatch path and the Query objects of query deletes (code reading). Besides the binary update value, the probes found only the varargs arrays of deleteDocuments kept after the call (a sibling candidate); the delete queue also keeps Query objects, which were not probed; the other probed arguments are copied or consumed during the call.
  • Not formal. No model, proof or exhaustive check produced the signal: the unit's bounded-exhaustive harness uses a fresh BytesRef per call and cannot see it.
  • Then an independent reproducer, reviews by an independent reviewer from another model family (OpenAI GPT-5.6), a devil's-advocate checker, an adjudication and an audit re-ran it with their own tests (Checks below).

Prior reports, history and checks

Prior reports

No prior report found (133 automated and 34 manual searches of GitHub, JIRA, four mailing lists, downstream projects and the web, 2026-10-27). Closest: 2014 comments on LUCENE-5189 warning that the Term of updateNumericDocValue is not cloned; Term was made to copy in 2015 (LUCENE-6821).

Searches5 searches

Verdict: new (no report of the same bug found). Not fixed upstream either: upstream main a95c754ee003 (2026-10-26) and branch_10x still store the reference.

Closest items: LUCENE-5189 / #6253, two comments of 2014-03-15 (mkhludnev: "When you call IW.updateNumericDocValue(Term, String, Long) make sure that the term is deeply cloned before. Otherwise, if you modify term or bytes, then the modified version will be applied."; shaie: deleteDocuments(Term) does the same, "the Term isn't cloned internally"). Same class of hazard, but about the Term of a numeric update, a different object with a different fix site; the binary value and the flushed-versus-buffered difference never come up. LUCENE-6821 / #7879 (2015): Term copies, the precedent for the fix. LUCENE-6321 / #7382 (2015, won't fix): "an abuse case", the likely objection. LUCENE-5513 / #6576 (2014): the introduction, no discussion of copying.

Gaps: GitHub search does not index code; the list API returns only the first 400 characters of a mail body.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-2748 issue/PR and 6 commit queries in two automated runs, plus 24 manual searches (also Lucene.NET, Solr, other search servers and all of GitHub); 7 issues/PRs read in full with comments and reviewsno report of this defect
JIRA archive (LUCENE, SOLR)2026-10-2712 automated and 10 manual queries; the full text of 15 issuesclosest: LUCENE-5189 comments (Term of a numeric update); LUCENE-6821 (Term copies); LUCENE-6321 (won't fix, 'abuse case')
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-2740 queriesno report
Downstream repositories and the web2026-10-278 downstream queries (three search servers) and 19 web queriesno report
Open PRs (apache/lucene)2026-10-27the file lists of all 409 open PRs; the patches of the 21 that touch the doc-values update filesnone changes BytesRef handling

History

Introduced with the API in 4.8.0 (LUCENE-5513, 2014); the Field overload since 4.9.0. All 116 releases from 4.8.0 to 10.5.2 and main fail. Until 7.6.0 flushed documents got the late bytes too; LUCENE-8590 (7.7.0) narrowed it to buffered documents.

Versions tested6 rows

Introduced by LUCENE-5513 "Binary DocValues Updates" (JIRA, opened 2014-03-09, migrated as #6576; Subversion era, no pull request), committed by shaie on 2014-03-18 as "LUCENE-5513: add IndexWriter.updateBinaryDocValue": trunk c5263086b4da, branch_4x backport 019b0c879cbc, which 4.8.0 contains. It added BinaryDocValuesUpdate, whose constructor stores the reference. No later commit added a copy; LUCENE-8282 and LUCENE-8298 refactored the class in 2018 and kept the reference. The second entry point, updateDocValues(Term, Field...), came with LUCENE-5680 (de8e86b5d094 / f99a9d7ab049, 2014-05-29, 4.9.0).

Narrowed in 7.7.0 by LUCENE-8590 "Optimize DocValues update datastructures" (2018-12-04, eaf840867fad on branch_7x / b0e6a92ede32 on master): its FieldUpdatesBuffer copies the bytes when the global slice is applied inside the call, so flushed segments became correct; buffered documents stayed wrong.

Every era ran on JDK 25.0.3; the 4.x-6.x programs use RAMDirectory, 7.0 and later a file-system directory. The follow-up notes that neither takes part in when the update bytes are copied.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affected4 of 5 tests fail (gradle and repro harness, assertions on and off): buffered=c, new2 x3, src2 x3, x2 x2
7.7.0 to 10.5.2 (every release) affectedflushed b, buffered c; the BytesRefBuilder loop stores new2 new2 new2
4.8.0 to 7.6.0 (every release) flushed tooflushed c, buffered c; the loop stores new2 new2 new2 (the Field overload from 4.9.0)
4.7.2 API absentthe program does not compile: updateBinaryDocValue does not exist
callers that pass BytesRef.deepCopyOf (control) not affectedcorrect values on every era tested, 4.8.0 to main
fix option A on main not affectedthe repro 5/5 and the doc-values update suites pass

Independent checks

An independent reproducer, OpenAI GPT-5.6 reviews (refute and works-as-designed lenses), a devil's-advocate checker, an adjudication and an audit all judged it real and re-ran it, all but the adjudication with tests of their own. The only objection that partly survives covers the reused-Field case.

Checks and verdicts559 words

All checks executed code on main a9779ef3c193, except the GPT-5.6 reviews, which ran on main ea57360912ed (none of the 7 commits in between touches the files involved; a separate run re-ran one review's test on a9779ef3c193).

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidate3-test JUnit fails 3/3 with assertions on and off, 60/60 over 20 iterations of one random root seed (tests.iters=20); fails on 10.5.2 and 9.12.3 (JUnit) and 8.11.4, 8.0.0, 7.7.3, 7.0.0 (plain programs). The one-line copy passes it and keeps TestBinaryDocValuesUpdates + TestMixedDocValuesUpdates at 38/0.
review, refute lens (OpenAI GPT-5.6, executed) real, high confidenceIts own 4-test JUnit failed 3 of 4 in every iteration with assertions on and off; the flushed-target control passed; with the fix 8/0 and the upstream suites 38/0. Rated the Field-NOTE argument moderate.
Independent reproducer realOwn public-API JUnit (4 probes, 2 controls): 4/6 fail with assertions on and off, each probe 20/20 over 20 iterations of one root seed (tests.iters=20, assertions off), controls 0/40; 10.5.2 and 9.12.3 reproduce; the fix makes 6/6 pass.
Works-as-designed review (OpenAI GPT-5.6, executed) real, high confidence; intended argument moderateIts own test fails 2 of 4 on main and 10.5.2; re-run separately on a9779ef3c193: 2 of 4 fail with assertions on and off, 4/4 pass with the fix.
Devil's advocate realNo intended, documented or misuse argument survives for updateBinaryDocValue; only the reused-Field case has a plausible misuse reading. Found the pass-through (src2 x3) and the fresh-BytesRef-over-reused-byte[] variants; its controls (addDocument reuse, setBytesValue idiom, flushed targets) pass; 5/5 with the fix.
Adjudication confirmed, high confidence, not a duplicateRe-ran the independent reproduction's test with the new seed 1CEB00DA: 4/6 fail with assertions on and off, the same values; controls pass.
Credibility audit keepA new public-API JUnit fails 2/4 with assertions on and off (changed after return, read through an NRT reader; reader pass-through); the fresh-BytesRef and change-after-next-addDocument controls pass.
This doc's runs reproduced; fix validatedThe doc's 5-test JUnit through gradle on a scratch worktree: stock 4/5 fail, with the fix 5/5 pass (assertions on and off); harness: the same 4/5 on main and 10.5.2.
Fact check of this doc (independent) reproduced; 2 text errors, fixedRe-ran the doc's gradle command in its own clone (4 of 5 fail, assertions on and off, lines identical), fix A (5/5; the update suites 116 tests, 0 failures), the harness on main (two seeds) and 10.5.2, the audit's test with the fix (4/4), and its own default-config probe. Errors: the prior-search count (133, not 124) and a RAM-estimate side effect of the fix that does not exist; both fixed.
Fact check of this doc (OpenAI GPT-5.6, read-only) core finding holds; wording correctedA reading-only review: 13 factual errors (0 high, 11 medium, 2 low), 7 overclaims and 8 missing caveats, none of which invalidates the main finding; it rated the "intended" case moderate for the direct API and strong for the reused-Field case. Every item was fixed or answered.
Diagram check passEvery on-screen value, API name and line matched the code and the probe; it also ran the pass-through with an unmodified new IndexWriterConfig() (src2 x3), which backs the summary card's default-config note. Nine minor layout and wording items, none blocking.

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

Finding record

LFV-643, snapshot of 2026-11-06

Registry title
IndexWriter.updateBinaryDocValue and updateDocValues(Term, BinaryDocValuesField) keep the caller's BytesRef without copying it, so documents still buffered in RAM get the bytes that BytesRef holds at the writer's next document or flush instead of the value passed to the call (every release since 4.8.0)
Area
index / IndexWriter doc-values updates (BinaryDocValuesUpdate, DocumentsWriterDeleteQueue slices, FieldUpdatesBuffer)
Priority
Medium, rank 17: The core IndexWriter binary doc-values update silently commits the wrong value for buffered documents on default config whenever the caller reuses its BytesRef, Lucene's own scratch-buffer idiom, and a one-line copy fixes it.
Impact class
silent wrong results
Impact note
Hits applications that update binary doc values (updateBinaryDocValue, or updateDocValues with a BinaryDocValuesField) with a BytesRef they reuse or change after the call, or with a reader-owned BytesRef passed straight through, for documents added since the last flush or refresh; default IndexWriterConfig and codec, one thread, deterministic, no scale, the same with -ea and -da [exec]; the API itself is rarely used (no Lucene module calls it outside tests, and a read-only code search found no common downstream server that does).
Summary
You call IndexWriter.updateBinaryDocValue(term, field, value), or updateDocValues(term, binaryDocValuesField), and afterwards reuse or change that BytesRef, for example one BytesRefBuilder scratch for a loop of updates or a BinaryDocValues.binaryValue() taken from another reader; each document should get the value it was passed. Documents in flushed segments do, but documents still buffered in RAM get whatever bytes the BytesRef holds when the writer next adds a document or flushes, so a loop over one scratch buffer gives every buffered target the last value. The wrong value is committed silently: no exception, CheckIndex is clean, and the outcome depends on whether the target document had already been flushed.
Introduced
c5263086b4da47ae0331678f1ec64dc1e7e3577b on trunk / 019b0c879cbc8d788152dc0693ff2c7d6e88ccda on branch_4x (the backport that 4.8.0 contains), LUCENE-5513 'add IndexWriter.updateBinaryDocValue' (JIRA LUCENE-5513 = apache/lucene#6576; Subversion era, no pull request) [read: git log, tag --contains and blame; exec: 4.7.2 has no such API, 4.8.0 fails]. It added BinaryDocValuesUpdate, whose constructor stores the BytesRef reference, and updateBinaryDocValue, which passes the caller's BytesRef straight in; no later commit added a copy (git log -S deepCopyOf on DocValuesUpdate.java finds none; LUCENE-8282 and LUCENE-8298 refactored the class in 2018 and kept the reference). The second entry point, updateDocValues(Term, Field...), came with LUCENE-5680 (de8e86b5d094227eeca7abf56083ee598687b0e6 on branch_4x / f99a9d7ab0497b88f5a49dbe7b7bbb958756aa1a on trunk) and passes f.binaryValue() into the same constructor. (2014-03-18 (LUCENE-5513); 2014-05-29 (LUCENE-5680, second entry point))
First affected version
4.8.0 [exec: fails, flushed=c buffered=c; 4.7.2, the last release before, cannot compile the program because updateBinaryDocValue does not exist]; 4.9.0 for updateDocValues(Term, Field...) [exec]
Affected versions
every release from 4.8.0 through 10.5.2 (the last release before 10.6.0) and main a9779ef3c193; never fixed. [exec] 116 releases (4.8.0-4.10.4 and every 5.x, 6.x, 7.x, 8.x, 9.x and 10.x release) plus main, 117 targets, all fail with era-adapted public-API main() programs; the JUnit fails on 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da), and the independent reproduction reproduced 10.5.2 and 9.12.3 with its own test. How it shows: on 4.8.0-7.6.0 every updated document, flushed or buffered, reads the late bytes; from 7.7.0 on (LUCENE-8590, whose FieldUpdatesBuffer copies the bytes when the global slice is applied inside the call) flushed segments get the value as of the call and only documents still buffered in RAM read late bytes; the reused-buffer loop is wrong on every release. [read] upstream main a95c754ee003 (2026-10-26) and branch_10x still store the reference.
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
non-formal: code reading
Upstream and prior reports
none found (2026-10-27, the prior-report record: 133 scripted prior-report search queries (66 + 67 in two runs) over GitHub issues/PRs/commits, downstream repositories, four mailing lists, JIRA and the web, plus 24 manual GitHub and 10 manual JIRA searches, the full text of 15 JIRA issues and 7 GitHub issues/PRs, and the file lists of all 409 open PRs; the finder, the intended-behaviour check and the adjudication found nothing either). Not fixed upstream: main a95c754ee003 and branch_10x still store the reference, and none of the 21 open PRs that touch the doc-values update files changes BytesRef handling. Closest prior discussion (a different argument): two comments of 2014-03-15 on LUCENE-5189 / #6253 by mkhludnev and shaie, warning that the Term passed to updateNumericDocValue (and deleteDocuments) is not cloned; LUCENE-6821 / #7879 (2015, Term(String, BytesRef) copies, called 'a bug fix') closed the common route for that Term half and is the precedent for the fix; LUCENE-6321 / #7382 (won't fix) called mutating term bytes after construction 'an abuse case', a likely objection.
Lucene commit tested
main a9779ef3c193 (2026-10-26): the doc's JUnit through gradle on a scratch worktree and through the hunt repro harness, assertions on and off; the 10.5.2 release jars; the follow-up's era-adapted public-API programs on all 116 releases from 4.8.0 to 10.5.2 (4.7.2 lacks the API). The checkers' tests ran on main a9779ef3c193 or ea57360912ed; none of the 7 commits between them touches the files involved.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:131-132 ('flushing just moves the internal buffered state in IndexWriter into the index'), :1981-2018 (updateBinaryDocValue javadoc 'to the given value'; line 2013 passes the caller's BytesRef) and :2113 (updateDocValues(Term, Field...) passes f.binaryValue())
  • lucene/core/src/java/org/apache/lucene/index/DocValuesUpdate.java:108-121 (BinaryDocValuesUpdate stores the reference; prepareForApply 'it's a final value')
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:135-139, 198-215 (update node queued; only the global slice is applied in the call, under tryLock)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:361-389, 427-436 (per-thread slice applied at the next document or at flush)
  • lucene/core/src/java/org/apache/lucene/index/FieldUpdatesBuffer.java:207-211 (first copy of the bytes, byteValues.append)
  • lucene/core/src/java/org/apache/lucene/document/Field.java:190-191, 332-333 (NOTE: the provided BytesRef is not copied)
  • lucene/core/src/java/org/apache/lucene/index/Term.java:50-55 (Term copies its BytesRef)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1996-2018, 2070-2113 (both entry points pass the caller's BytesRef)
  • lucene/core/src/java/org/apache/lucene/index/DocValuesUpdate.java:108-122 (reference stored; prepareForApply)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:135-139, 198-215, 500-520 (queued node; global slice under tryLock; node apply)
  • lucene/core/src/java/org/apache/lucene/index/BufferedUpdates.java:146-156 and FieldUpdatesBuffer.java:207-212 (first copy)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:361-390, 427-439 (per-thread slice applied at the next document or at flush)
  • lucene/core/src/java/org/apache/lucene/index/FieldUpdatesBuffer.java:107-112 (the constructor copies a field's first update) and IndexWriter.java:2969-2990, 6783 (packet and segment delete generations)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1673-1674, 1946-1947 and DocumentsWriterDeleteQueue.java:154-173 (softUpdateDocument(s) queue the same update node; code reading)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:131-132, 1981-1995, 2060, 1759-1761
  • lucene/core/src/java/org/apache/lucene/index/BinaryDocValuesFieldUpdates.java:94-98; Term.java:44-55; DocValuesUpdate.java:117-122; BinaryDocValues.java:43-50
  • lucene/core/src/java/org/apache/lucene/document/Field.java:187-191, 329-335; BinaryDocValuesField.java:48-58
  • lucene/core/src/test/org/apache/lucene/index/TestBinaryDocValuesUpdates.java:69-83 (fresh BytesRef per call)
  • lucene/core/src/java/org/apache/lucene/util/BytesRefBuilder.java:151-158; Term.java:63-66; lucene/CHANGES.txt:12171-12173 (LUCENE-5703, under Lucene 4.9.0)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1674, 1724, 1947, 2013, 2113 (constructor callers)
  • lucene/core/src/java/org/apache/lucene/index/DocValuesUpdate.java:108-127 (constructors, prepareForApply, valueSizeInBytes)
  • lucene/core/src/test/org/apache/lucene/index/TestMixedDocValuesUpdates.java:601-628 (testResetValue: null binary value)

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 diagram probe's trace (a plain main() on the public API, run on main with assertions on and off and with the fix).

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.