All findings · Lucene finding · LFV-802 · try-update-doc-value-unvalidated-docid

IndexWriter#tryUpdateDocValue accepts a doc id outside [0, maxDoc), returns success and updates a different (or a nonexistent) document

IndexWriter#tryUpdateDocValue never checks its doc id: on a 4-document segment, id -1 returns success and writes the new value into doc 3, with assertions on or off. With assertions off, ids past maxDoc hit other documents or leave an entry for a nonexistent document that a merge spreads.

Summary

Priority
Low, rank 885Silent corruption of another document happens only when a caller passes an out-of-range id to the Expert tryUpdateDocValue, a contrived caller bug, but it has shipped since 7.4.0, a merge spreads the ghost entry, and the negative id gets past -ea.
Impact
data corruption
Since
7.4.0 (LUCENE-8297, 2018)introduced 2018-05-07 · about 8 years
Affected
7.4.0 to 10.6.0 (the latest release) and main
Status
documented
Found by
code reading while writing a try* test oracle (non-formal)

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

tryUpdateDocValue(reader, docID, fields) updates one document by its number in an NRT reader, but never checks that the number lies in [0, maxDoc). The update buffer stores each doc id in a few bits sized for the segment, so an out-of-range id can wrap onto another document, or survive as an entry for a document that does not exist.

Background: updating a document by its number

  1. Inside a segment, documents are numbered 0 to maxDoc - 1. A DirectoryReader is a composite reader that numbers documents across its segments; each of its leaf readers is one segment.
  2. IndexWriter#tryUpdateDocValue(reader, docID, fields) (Expert) changes the doc values of the one document that has number docID in an NRT reader of this writer. Writing the soft-deletes field this way soft-deletes that document.
  3. The writer buffers the change as (doc id, value) entries per segment, packed into a few bits sized for that segment's maxDoc (at least as many as its largest doc id needs, sometimes rounded up to 8, 16, 32 or 64), and returns a sequence number > 0 to say the update succeeded.
Diagram of the minimal repro: a 4-document segment with v = 0, 1, 2, 3; tryUpdateDocValue(leaf, -1, v=777) is not rejected, -1 is packed into the update buffer as doc 3, the call returns 7, and doc 3 ends with v = 777.
Doc id -1 on a 4-document segment: the update buffer keeps only the low 2 bits of each doc id (plus a flag bit), so -1 is stored as doc 3, and the call still returns success. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 4 documents with a numeric doc-values field v = 0, 1, 2, 3 (default config, one segment) and open an NRT reader with DirectoryReader.open(writer).
  2. Call writer.tryUpdateDocValue(leaf, -1, new NumericDocValuesField("v", 777)) on its only leaf reader, as if a "not found" value of -1 had slipped through your code.
  3. You expect the call to reject -1, as the same reader's stored fields do (IndexOutOfBoundsException), or at least to change nothing: -1 names no document.
  4. Instead the call returns 7, a value > 0 that the javadoc defines as "the update succeeds", and doc 3 now has v = 777. The update buffer of a 4-document segment keeps only 2 bits of each doc id, and the low bits of -1 read as 3.
  5. With assertions off (the production default) a composite reader does the same: on 8 documents, id 8 overwrites doc 0 and a soft-delete of id 9 hides doc 1; on 10 documents, id 12 commits an entry for a document that does not exist, CheckIndex calls it clean, and forceMerge(1) shifts the next segment's 10 values by one.

Who is affected

Applications that call the Expert tryUpdateDocValue (also used to soft-delete or un-delete one document) and, through their own bug, pass an id outside the reader: a -1 or NO_MORE_DOCS sentinel, an off-by-one, an id from another reader, a top-level id with a leaf reader. Default config; the call reports success at any segment size tried, so nothing warns them.

Mechanism, terms and numbers938 words

IndexWriter#tryUpdateDocValue(reader, docID, fields) (Expert) updates doc values of one document named by its number in an NRT reader, and is also the documented way to soft-delete or un-delete one document. Its helper tryModifyDocument, shared with tryDeleteDocument, never checks that the number lies in [0, reader.maxDoc()): the leaf-reader branch uses it as is, and the composite branch only asserts. The id goes into a per-segment update buffer that packs each doc id plus a flag bit into an entry of at least bitsRequired(maxDoc - 1) + 1 bits (the packed array may round the width up to 8, 16, 32 or 64 bits) and keeps only the entry's low bits; its only guard is assert doc < maxDoc. So on a 4-document segment, id -1 is stored as doc 3: the call returns 7 (a value > 0, "the update succeeds") and doc 3 gets the new value, with assertions on or off. On other segment sizes the same call still returns success but leaves an entry for a nonexistent document (doc 15 on 10 documents, doc 127 on 40, 64 or 100). With assertions off, the production default, ids at or past maxDoc on a composite reader wrap onto other documents (8 -> doc 0, 9 -> doc 1, NO_MORE_DOCS -> doc 7), or are kept as an entry for a nonexistent document, which the commit persists, CheckIndex calls clean and forceMerge(1) shifts onto the next segment's documents. Every trigger is a caller bug, so the priority is low; but the call reports success while it changes a different, valid document (or adds an entry for a nonexistent one). It is in every release since 7.4.0 and on main; a 7-line range check at the top of tryModifyDocument fixes it and passes the whole core test suite.

The code (main 645d4fe14a20)

// IndexWriter.java:1791-1805
private synchronized long tryModifyDocument(IndexReader readerIn, int docID, DocModifier toApply)
    throws IOException {
  final LeafReader reader;
  if (readerIn instanceof LeafReader lr) {
    // Reader is already atomic: use the incoming docID:
    reader = lr;                                       // :1796  no check at all
  } else {
    // Composite reader: lookup sub-reader and re-base docID:
    List<LeafReaderContext> leaves = readerIn.leaves();
    int subIndex = ReaderUtil.subIndex(docID, leaves); // id >= last docBase -> last leaf
    reader = leaves.get(subIndex).reader();
    docID -= leaves.get(subIndex).docBase;
    assert docID >= 0;                                 // :1803  only asserts
    assert docID < reader.maxDoc();                    // :1804
  }
  ...                                                  // :1823-1824  toApply.run(docID, rld); return seqNo

// DocValuesFieldUpdates.java
// :253  bitsPerValue = PackedInts.bitsRequired(maxDoc - 1) + SHIFT;   (SHIFT = 1, :42)
// :254  docs = new PagedMutable(1, PAGE_SIZE, bitsPerValue, PackedInts.DEFAULT);   (width may round up)
// :373  assert doc < maxDoc;                                          (-1 passes)
// :384  docs.set(size, (((long) doc) << SHIFT) | hasValueMask);       (only the entry's low bits survive)

Step by step, on the repro

  1. The buffer. tryUpdateDocValue creates a NumericDocValuesFieldUpdates sized for the segment's maxDoc (IndexWriter.java:1740-1744) and adds the caller's leaf id unchanged (:1756-1757).
  2. The width. Each entry has at least bitsRequired(maxDoc - 1) + 1 bits: PackedInts.DEFAULT lets the packed array round the width up to 8, 16, 32 or 64 bits when that costs at most 25% more (PackedInts.java:186-213, since LUCENE-8598 in 7.7.0), and Packed64.set does not mask, so only the low bits of that width survive. For maxDoc 4 the width is exactly bitsRequired(3) + 1 = 2 + 1 = 3 bits. -1 << 1 | 1 is all ones; its low 3 bits are 0b111; shifted back, that is doc 3.
  3. The guards. The leaf branch has none; the buffer's assert doc < maxDoc holds for -1. So the update is applied with assertions on or off, and the call returns docWriter.getNextSequenceNumber() (IndexWriter.java:1824): 7.
  4. Same arithmetic, other ids (assertions off, composite reader, where only the asserts at :1803-1804 stand in the way): maxDoc 8 gives 4 bits, so 8 -> doc 0, 9 -> doc 1 and NO_MORE_DOCS (2^31 - 1) -> doc 7. maxDoc 10 gives 5 bits, so 12 fits and is kept: an entry for a document that does not exist, written to the new doc-values generation at commit.
  5. Other segment sizes (leaf reader, id -1, assertions on and off): -1 lands on the last document only when the width is exact and maxDoc is a power of two (4, 8 or 16 documents). On 10 documents it becomes an entry for nonexistent doc 15, and on 40, 64 or 100 documents for nonexistent doc 127 (8-bit entries: 7 bits rounded up for 40 and 64, exactly 8 for 100). Every one of these calls returns success.
  6. After the commit the doc-12 entry is an extra FieldExistsQuery hit, and stored fields reject doc 12; CheckIndex does not flag that commit on any version tested, and forceMerge(1) shifts the next segment's values by one document.
call (on 645d4fe14a20: the study's 7-test class and the tie-break probe; values 1000 + id)assertionsreturnswhat changes
tryUpdateDocValue(4-doc leaf, -1, v=777)on and off8doc 3: 1003 -> 777
tryUpdateDocValue(4-doc leaf, -1, soft=1)on and off8doc 3 soft-deleted
tryUpdateDocValue(8-doc reader, 8, v=777)off12doc 0: 1000 -> 777
tryUpdateDocValue(8-doc reader, 9, soft=1)off12doc 1 soft-deleted (live docs 8 -> 7)
tryUpdateDocValue(8-doc reader, NO_MORE_DOCS, v=777)off12doc 7: 1007 -> 777
tryUpdateDocValue(10-doc reader, 12, v=777), commit, a second 10-doc segment, forceMerge(1)off14commit holds "doc 12 (maxDoc 10) v=777"; CheckIndex clean before and after; docs 10-19 then hold 777, 1010, ..., 1018 (1019 lost)
tryUpdateDocValue(composite reader, -1, ...)on and offthrows IndexOutOfBoundsExceptionnothing

With assertions on, the composite asserts stop the 8-doc and 10-doc rows before any change.

Exposure and likelihood272 words
  • Who is hit. Callers of the Expert IndexWriter#tryUpdateDocValue, including its documented uses to soft-delete and un-delete single documents (IndexWriter.java:1712-1715; CHANGES.txt:7417-7421), that pass an id outside the reader they pass. Plausible caller bugs: a -1 or NO_MORE_DOCS "not found" value, an off-by-one, an id from another or newer reader, or a top-level id together with a leaf reader (the study's probe, assertions off: two 10-document segments, the second segment's leaf with top-level id 17, meant for its doc 7, returns 26 and writes into its doc 1, because only the low 4 bits of 17 are kept).
  • No special setup. Default IndexWriterConfig and codec, a 4-document segment, one thread, deterministic. The leaf-reader -1 call returns success at every segment size tried (4 to 100 documents), with assertions on or off, so Lucene-style -ea test runs do not catch it; which document it hits depends on the size. The composite cases need assertions off, the production default.
  • What goes wrong. A different, valid document gets the value, or is soft-deleted and so disappears from search, while the call returns its success value; or the commit holds an entry for a document that does not exist, CheckIndex calls it clean, and the next merge shifts values across ten other documents and drops one.
  • What does not go wrong. Correct callers, with ids from the same NRT reader, are unaffected. No Lucene main code calls tryUpdateDocValue; IndexRearranger (lucene/misc) calls tryDeleteDocument with ids from a loop over maxDoc. No memory corruption: the wrong data goes through ordinary Java arrays.
  • Impact class: data corruption, likelihood contrived (needs a caller bug); priority low.

Minimal repro

One JUnit test on the public API: default IndexWriterConfig, ByteBuffersDirectory, 4 documents, single thread, deterministic. It passes on an IllegalArgumentException or when doc 3 keeps its value (it does not check docs 0-2), and fails with assertions on and off. Put it in lucene/core/src/test/org/apache/lucene/index/ and run it with gradle.

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

import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestTryUpdateDocValueNegativeDocId extends LuceneTestCase {

  public void testNegativeDocIdOnLeafReader() throws IOException {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      for (int i = 0; i < 4; i++) {
        Document doc = new Document();
        doc.add(new NumericDocValuesField("v", i));
        w.addDocument(doc);
      }
      long seqNo;
      try (DirectoryReader r = DirectoryReader.open(w)) {
        try {
          seqNo = w.tryUpdateDocValue(getOnlyLeafReader(r), -1, new NumericDocValuesField("v", 777));
        } catch (IllegalArgumentException rejected) {
          seqNo = -1; // rejecting the out-of-range docID is a correct outcome
        }
      }
      try (DirectoryReader r = DirectoryReader.open(w)) {
        NumericDocValues v = getOnlyLeafReader(r).getNumericDocValues("v");
        assertTrue(v.advanceExact(3));
        // Expected: docID -1 changes no document (doc 3 keeps 3); actual: seqNo > 0 and doc 3 = 777.
        assertEquals("tryUpdateDocValue(leaf, -1) returned " + seqNo + "; doc 3", 3, v.longValue());
      }
    }
  }
}
Command
cp TestTryUpdateDocValueNegativeDocId.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE
./gradlew :lucene:core:test --tests TestTryUpdateDocValueNegativeDocId -Ptests.seed=DEADBEEF
# assertions off (production-like): add -Ptests.asserts=false
Expected
tryUpdateDocValue(leaf, -1, ...) throws IllegalArgumentException or changes nothing: doc 3 keeps v = 3.
Observed
It returns 7 (success) and doc 3 has v = 777: tryUpdateDocValue(leaf, -1) returned 7; doc 3 expected:<3> but was:<777>.

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

More files, environment, full output214 words

The test lives in package org.apache.lucene.index by Lucene test convention but uses no package-private members. The seed sets the test framework's random locale, time zone, default codec, similarity and a few other settings; the outcome does not depend on them: 10 of 10 and 20 of 20 derived seeds fail. Use this one test as the regression test too.

The same failure, by the hunt repro harness: main 645d4fe14a20, 10.5.2 and 10.6.0, assertions on and off. A framework-free main() with the stock codec prints the same result. The wider assertions-off cases (the table under "The finding in 30 seconds") come from the study's 7-test class and are supporting evidence, not part of the repro.

Environment

lucenemain 645d4fe14a20, 10.6.0 and 10.5.2: reproduced, assertions on and off; every release from 7.4.0: reproduced; 7.3.1: no tryUpdateDocValue
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on (codec=Asserting(Lucene104))
TestTryUpdateDocValueNegativeDocId > testNegativeDocIdOnLeafReader FAILED
    java.lang.AssertionError: tryUpdateDocValue(leaf, -1) returned 7; doc 3 expected:<3> but was:<777>
        at org.apache.lucene.index.TestTryUpdateDocValueNegativeDocId.testNegativeDocIdOnLeafReader(TestTryUpdateDocValueNegativeDocId.java:48)
1 test completed, 1 failed

# gradle, same, -Ptests.asserts=false: the same failure and message
# repro harness, the same message: main, 10.6.0 and 10.5.2, -ea and -da (1 run, 1 failed each)
# with the fix: the test passes (gradle, assertions on and off)

Why it looks unintended

  • The javadoc says a return value > 0 means "the update succeeds"; it states no doc-id precondition.
  • Asserts on the composite path and in the buffer show such ids are not meant to get through; production runs without assertions, and the leaf path has none.
  • The same reader rejects -1 with an exception; here a different, valid document changes silently.
The full argument287 words
  • Contract. IndexWriter.java:1707-1712: "If the provided reader is an NRT reader obtained from this writer, and its segment has not been merged away, then the update succeeds and this method returns a valid (> 0) sequence number; else, it returns -1 ...". No doc-id precondition and no "undefined" clause. CHANGES.txt:7417-7421 (LUCENE-8297): "With tryUpdateDocValues users can update doc-values fields for individual documents. This allows for instance to soft-delete individual documents."
  • The asserts show such ids are not meant to get through (an inference from the code; no comment says so). The composite branch has asserted docID >= 0 and docID < reader.maxDoc() since LUCENE-4203 (2012), and the buffer doc < maxDoc since LUCENE-7868 (2017). When tryDeleteDocument was added (4.0), its delete path also asserted the range (ReadersAndLiveDocs.delete), on the leaf branch too; with assertions off, the 4.0 BitVector threw ArrayIndexOutOfBoundsException for most bad ids, though ids between maxDoc and the next multiple of 8 returned true and deleted nothing (executed on the 4.0.0 jars). LUCENE-8297 (2018) then sent the same unchecked id into a packed buffer that truncates it; its discussion (#9344) never mentions validation.
  • Lucene rejects out-of-range ids elsewhere on the same reader. BaseCompositeReader.java:261-266 throws IllegalArgumentException "docID must be >= 0 and < maxDoc"; CodecReader.java:93-101 checks with Objects.checkIndex under the comment "Don't trust the codec to do proper checks"; TestDirectoryReader.testOOBDocID (:1090-1097) expects the exception. IndexWriter#addBatch checks its doc ids too (ColumnValidation.java:297-307).
  • The same call rejects other bad input with IllegalArgumentException, for example a field without doc values ("can only update NUMERIC or BINARY fields! field=id", IndexWriter.java:2090-2093), executed in the tie-break probe.
  • No cost argument. The method is synchronized and already does a linear search over segments (IndexWriter.java:1814-1819).
The devil's-advocate check276 words

Strongest case for "intended" (caller misuse, so the fix is hardening): a doc id is by definition in [0, maxDoc) (IndexReader.java:51-52: "non-negative integers which each name a unique document"); these methods are Expert; Lucene often documents out-of-range ids as the caller's problem (Bits.get: "The result of passing negative or out of bounds values is undefined by this interface, just don't do it!", Bits.java:30-31; DocValuesIterator.advanceExact: the target "must be a valid doc ID") and guards internal invariants with asserts; in LUCENE-4203 (#5275) the issue's reporter and committer, Michael McCandless, wrote that "you should use an NRT reader from IW anyway to even find the docID to delete"; and open PR #16847 keeps the range check assert-only. All read-only cross-model reviews made this point.

Why it fails:

  1. Those "undefined" clauses are stated in the javadoc of read APIs; tryUpdateDocValue's javadoc has none, and it is a write API that persists the damage.
  2. The damage is not confined to the caller's bad input: a different, valid document changes, under a success return value.
  3. The writer commits an internally inconsistent index (a doc-values entry beyond maxDoc) that CheckIndex accepts and a merge spreads onto other documents.
  4. The leaf branch has not even an assert, so Lucene's own -ea test runs cannot catch the negative-id case.
  5. The same reader, the same call and addBatch reject bad input with exceptions; the merged #16919 (2026-10-28) turned more explicit AssertionErrors into proper exceptions in other files, the same direction.

What remains is framing (bug or hardening), which sets the low priority, not the verdict. A "hardening" label is likely; the trigger is a caller error.

Suggested fix

Recommended A. Range-check the id at the top of tryModifyDocument (7 lines)

At the top of tryModifyDocument, which both try* methods share, throw IllegalArgumentException("docID must be >= 0 and < maxDoc=...") when docID < 0 || docID >= readerIn.maxDoc(), as BaseCompositeReader does (7 lines). The repro passes, as do the 10 closest suites (246 tests in 11 classes with the repro, assertions on and off) and all 8,898 core tests.

@@ -1791,6 +1791,13 @@ public class IndexWriter
   private synchronized long tryModifyDocument(IndexReader readerIn, int docID, DocModifier toApply)
       throws IOException {
     final LeafReader reader;
+    // Validate up front, like BaseCompositeReader#readerIndex: an out-of-range id would otherwise be
+    // truncated to the segment's doc-id width by the doc-values update buffer and land on another
+    // (or a nonexistent) document.
+    if (docID < 0 || docID >= readerIn.maxDoc()) {
+      throw new IllegalArgumentException(
+          "docID must be >= 0 and < maxDoc=" + readerIn.maxDoc() + " (got docID=" + docID + ")");
+    }
     if (readerIn instanceof LeafReader lr) {
All options and trade-offs4 options ยท 514 words

A is validated and recommended. Open PR #16847 (long doc ids) rewrites these lines with an assert-only toLeafDocId; the check belongs before the id is re-based. Workaround for users today: check 0 <= docID && docID < reader.maxDoc() for the exact reader you pass before calling tryUpdateDocValue or tryDeleteDocument.

A. Range-check the id at the top of tryModifyDocument (7 lines) (validated by the checks) (recommended)

Covers both branches, negative ids and both try* methods, with the message BaseCompositeReader#readerIndex already uses; constant cost.

@@ -1791,6 +1791,13 @@ public class IndexWriter
   private synchronized long tryModifyDocument(IndexReader readerIn, int docID, DocModifier toApply)
       throws IOException {
     final LeafReader reader;
+    // Validate up front, like BaseCompositeReader#readerIndex: an out-of-range id would otherwise be
+    // truncated to the segment's doc-id width by the doc-values update buffer and land on another
+    // (or a nonexistent) document.
+    if (docID < 0 || docID >= readerIn.maxDoc()) {
+      throw new IllegalArgumentException(
+          "docID must be >= 0 and < maxDoc=" + readerIn.maxDoc() + " (got docID=" + docID + ")");
+    }
     if (readerIn instanceof LeafReader lr) {

Trade-offs

Callers that pass out-of-range ids get an exception instead of silent success. For tryDeleteDocument that replaces today's "> 0 but nothing deleted" (maxDoc 10, id 10) or ArrayIndexOutOfBoundsException (maxDoc 64, id 64); a composite -1 changes from IndexOutOfBoundsException to IllegalArgumentException; an out-of-range id on a reader that is not a SegmentReader now gets the range message instead of "the reader must be a SegmentReader ...", since the check runs first. With #16847's long ids the same check works on the long.

What the checks validated

  • Scratch worktree of main 645d4fe14a20 (removed afterwards), seed DEADBEEF: stock, the test fails through gradle with assertions on and off; with A it passes.
  • Closest suites with A, -Ptests.asserts=true and false: the repro, TestTryDelete, TestRollingUpdates, TestIndexWriter, TestMixedDocValuesUpdates, TestNumericDocValuesUpdates, TestBinaryDocValuesUpdates, TestInfoStream, TestIndexWriterDelete, TestSoftDeletesRetentionMergePolicy (every core test that calls a try* method, plus the doc-values update suites) and lucene/misc TestIndexRearranger (the in-tree tryDeleteDocument caller): 246 tests in 11 classes, 9 skipped, 0 failures each time.
  • All of :lucene:core:test with A (assertions on): 8,898 tests in 749 classes, 322 skipped, 0 failures.
  • Earlier, with A shadowed in: the study's 4-test and 7-test classes pass with assertions on and off.

B. Check per branch, after re-basing

Replace the two composite asserts with if ... throw and add the same check on the leaf branch, or make #16847's LeafReaderContext.toLeafDocId throw instead of assert.

Trade-offs

Same result; more lines, and it must also handle ReaderUtil.subIndex returning -1 for a negative composite id. Not run.

C. Also guard the update buffer

Turn assert doc < maxDoc (DocValuesFieldUpdates.java:373) into a range check that throws.

Trade-offs

Defence in depth for any future caller, but it sits on the internal path of every doc-values update, where ids are already valid; A alone fixes this bug. Not run.

D. Javadoc only

Document that the id must be in [0, reader.maxDoc()).

Trade-offs

Leaves silent success with a different document changed; not recommended.

Downstream note

Only the application's own code picks the doc id; a product that soft-deletes or flags documents through this call would, after such a caller bug, hide or flag the wrong document.

How it was found

Code reading during a hunt unit that stress-tested tryDeleteDocument and tryUpdateDocValue against flush, merge and commit. While writing a "returns success but changed a different document" check, the finder saw that the doc id is only asserted. Stock-jar probes with assertions off confirmed the effect; the concurrency testing itself did not find it.

The method in full116 words
  • Method (non-formal): code reading in a hunt unit that ran controlled-concurrency testing (Fray) and stress tests of tryDeleteDocument and tryUpdateDocValue against flush, merge and commit.
  • Signal. While writing that unit's oracle "returns > 0 and changes a different document", the finder noticed that tryModifyDocument only asserts the doc id. Probes on the stock main jar with assertions off showed the wrong-document and nonexistent-document effects.
  • Reduction. The finder wrote a public-API JUnit (4 tests); an independent reproducer's own 7-test class found that the leaf-reader -1 case fails with assertions on too; a later minimization kept only that case, as the one test above.
  • The unit's Fray and stress hunt found other registered try* bugs, not this one.

Prior reports, history and checks

Prior reports

No prior report: 264 queries (2026-10-27) and 232 (2026-10-31) over GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream projects and the web. Open PR #16847 rewrites the same lines and keeps an assert-only check.

Searches2 searches

Nearest items, none this bug: open PR #16847 (long doc ids; its LeafReaderContext.toLeafDocId only asserts, nobody there discusses validation); #9344 / LUCENE-8297 and #5275 / LUCENE-4203 (added the code, no validation discussed); open #7444 / LUCENE-6384 (rare false returns of tryDeleteDocument); #5587, #6050, #6803 (other try* bugs, valid ids); #16919 (merged 2026-10-28, assertions to exceptions in other files). Limits: keyword search cannot prove absence; GitHub search does not split camelCase; private channels are invisible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories, JIRA archive, mailing lists, web2026-10-27264 logged queries (four scripted prior-report search runs, 37 manual GitHub searches over Lucene, Elasticsearch, OpenSearch, Solr and Lucene.NET, 11 web searches); file lists of all 409 open PRs, patches of the 38 touching the involved files grepped22 hits read, none this bug
Refresh: GitHub, open PRs, mailing lists, JIRA archive, web2026-10-31232 logged queries (three tool runs, 26 manual GitHub searches, 9 list and 6 web queries); all 410 open PRs; every issue and PR #16900-#16958; #16847's patch, reviews and commentsnothing new; #16847 still open, unchanged since 2026-10-20

History

Introduced in 7.4.0 by LUCENE-8297 (#9344, 2018), which added tryUpdateDocValue and first let a caller's id into the doc-values update buffer. All 68 releases from 7.4.0 to 10.6.0 fail the repro, assertions on and off; 7.3.1 has no such API. Not fixed on main.

Versions tested5 rows

Introduced by LUCENE-8297 "Add IW#tryUpdateDocValues(Reader, int, Fields...)" (#9344, a JIRA-era patch), committed 2018-05-07 as 48a2138e899 (branch_7x) and b0b32931b28 (master) with the same core patch. Older ingredients: the unchecked leaf branch and the assert-only composite check (LUCENE-4203, 21e7728e573, 2012, for tryDeleteDocument); the bitsRequired(maxDoc - 1) entry width (LUCENE-5248, fc6f94f57e1, 2013); assert doc < maxDoc (LUCENE-7868, 58105a203a1, 2017). Before LUCENE-8297 only Lucene's own in-range ids reached that buffer. 148 harness runs in total. Still unchanged on upstream main 2f424fd428da, branch_10_6 and branch_11x (2026-10-31).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one test fails (gradle and harness, -ea and -da)
10.6.0 affectedthe one test fails (-ea and -da)
7.4.0 to 10.5.2 (67 releases) affectedthe one test fails (-ea and -da); only the returned sequence number varies (8 or 7)
48a2138e899 and b0b32931b28 (LUCENE-8297) vs parents 0600a58f005, 6521d86dded commit runsboth LUCENE-8297 commits fail (returned 8, doc 3 = 777); the parents lack the API
7.3.1 API absentno tryUpdateDocValue: the test does not compile

Independent checks

An independent reproducer, an intended-behaviour devil's advocate, an adjudication (confirmed, medium), a credibility audit, a current-main re-check and a tie-break ran repros and found it real. Cross-model reviews (OpenAI GPT-5.6) ran no code and all returned "not real": three on scope (a caller error), the fourth only because it could not reproduce anything.

Checks and verdicts364 words

Each check had its own lens. Everything except the cross-model reviews was executed; for the read-only reviews a separate run re-ran the repro (it failed every time).

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realOwn public-API 7-test class: 7 of 7 fail with assertions off, 2 of 7 with them on (found the leaf-reader -1 gap), on main, 10.5.2 and 9.12.3; 7 of 7 pass with the fix.
intended-behaviour devil's advocate real, mediumBuilt the strongest "caller misuse" case and rejected it; probe facts: the same reader and the same call reject bad input, and a top-level id with a leaf reader writes another document.
cross-model reviews (refute, intended, root-cause lenses) not real, on scope onlyRead-only (they could not run any command). All three agree on the root cause, and the root-cause review also says the up-front guard addresses it; all three still vote "not real" because the id is outside the doc-number domain, so the fix may be called hardening.
adjudication confirmed, mediumRe-ran the 7-test class (7 of 7 and 2 of 7); narrowed the claim to tryUpdateDocValue; weighed the scope objection as framing, not verdict.
credibility audit keepFresh seeds: 7 of 7 fail with assertions off, 2 of 7 with them on.
current-main re-check realThe one test fails on main 645d4fe14a20 and 10.5.2, assertions on and off; passes with the fix.
tie-break (after a read-only refute review) real, medium-highThe cross-model refute review could not run code; it voted "not real" only for lack of a reproduction and took no position on the root cause. The tie-break re-ran everything on 645d4fe14a20 and 10.6.0, with and without the test framework, plus valid-id controls (id 3 also returns 7).
fact checks (2026-10-31) real; corrections appliedAn independent fact check re-ran the repro, the fix, the 7-test class and new probes on main and on the 4.0.0 jars; a cross-model fact check (OpenAI GPT-5.6, reading only) reviewed the text. Both found the bug real and correctly not security-sensitive; their corrections (entry width rounding, the 4.0 delete path, CheckIndex versions, the review summary, wording) are applied.

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

Finding record

LFV-802, snapshot of 2026-11-06

Registry title
IndexWriter#tryUpdateDocValue does not validate the doc id: an id outside [0, maxDoc) returns success and silently updates or soft-deletes a different document, or commits a doc-values entry for a nonexistent document that a merge shifts onto other documents
Area
index / IndexWriter tryUpdateDocValue (tryModifyDocument doc-id validation, doc-values update buffer)
Priority
Low, rank 885: Silent corruption of another document happens only when a caller passes an out-of-range id to the Expert tryUpdateDocValue, a contrived caller bug, but it has shipped since 7.4.0, a merge spreads the ghost entry, and the negative id gets past -ea.
Impact class
data corruption
Impact note
Only applications that call the Expert tryUpdateDocValue (including its soft-delete and un-delete uses) with a doc id outside [0, reader.maxDoc()) because of their own bug (an id from another reader, an off-by-one, a -1 or NO_MORE_DOCS sentinel, or a top-level id passed with a leaf reader) are hit; with the default IndexWriterConfig and codec and assertions disabled (the production default) the call returns its success value while it changes or soft-deletes a different document of the same segment, or commits an entry for a nonexistent document that the next merge shifts onto the next segment's documents; a negative id on a leaf reader does this with assertions enabled too; no scale is needed (an 8-document segment), CheckIndex on 10.x and main does not notice, and it is in every release from 7.4.0 through 10.6.0 and main.
Summary
You call IndexWriter#tryUpdateDocValue with an NRT reader and a doc id outside [0, reader.maxDoc()), for example 8 or 9 on an 8-document reader, 12 on a 10-document reader, or -1 on a 4-document leaf reader. You expect the call to be rejected, as the same reader rejects such an id ('docID must be >= 0 and < maxDoc', IllegalArgumentException), or at least to leave every other document alone. Instead, with assertions disabled (and for the negative leaf-reader id even with them enabled) it returns a sequence number > 0, which the javadoc says means the update succeeded, and writes the value to or soft-deletes a different live document (8 -> doc 0, 9 -> doc 1, -1 -> doc 3), or commits a doc-values entry for nonexistent doc 12 that CheckIndex accepts and that forceMerge turns into every document of the next segment carrying its predecessor's value (b0=777, b1=1000, ..., b9=1008; 1009 is lost).
Introduced
48a2138e899 (branch_7x, released in 7.4.0); the same change on master is b0b32931b28 (released from 8.0.0), identical core patch-id: LUCENE-8297 = apache/lucene#9344 (s1monw), 'Add IW#tryUpdateDocValues(Reader, int, Fields...)', which put tryUpdateDocValue on top of tryModifyDocument and created the first public path from a caller's doc id into the doc-values update buffer, with no range check [exec: the parents 0600a58f005 and 6521d86dded lack the API (the repro does not compile); both LUCENE-8297 commits fail with the same wrong documents as 7.4.0, before LUCENE-8298 changed the packed entry layout]. Pre-existing ingredients [read]: tryModifyDocument's assert-only check on the composite branch and the unchecked leaf-reader branch date from LUCENE-4203 (21e7728e573, 2012-08-02); the buffer's bitsRequired(maxDoc - 1) entry width from LUCENE-5248 (fc6f94f57e1, 2013); its only guard, 'assert doc < maxDoc', from LUCENE-7868 (58105a203a1, 2017), when only Lucene's own in-range ids reached that buffer (2018-05-07 (committer date of both LUCENE-8297 commits; author date 2018-05-05))
First affected version
7.4.0 [exec] (7.3.1 and earlier have no tryUpdateDocValue; 7.3.1 executed: the repro does not compile)
Affected versions
all 67 releases from 7.4.0 through 10.5.2 on Maven Central [exec, assertions off: the finder's repro fails 4 of 4 in each], the branch_10_6 head 4ae946e5a9b (10.6.0, released 2026-10-29) and main a9779ef3c193 [exec]; the independent reproduction's test fails 7 of 7 with assertions off and 2 of 7 with assertions on (the leaf-reader id -1 cases) on 7.4.0, 7.7.3, 8.11.4, 9.12.3, 10.5.2, branch_10_6 and main, and 7 of 7 with assertions off on 10.0.0 [exec]; 102 executed history runs plus commit-level runs at LUCENE-8297. Not fixed upstream (2026-10-27): upstream main is 4 commits ahead of a9779ef3c193 and none touches IndexWriter or the doc-values update classes; the main, branch_10x and branch_10_6 heads still have only the two asserts [read]; open PR #16847 rewrites tryModifyDocument but keeps an assert-only range check. The tryDeleteDocument variant (secondary, not claimed) dates from LUCENE-4203 (4.0) [exec on 7.3.1 only]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), 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 (the prior-report record verdict new, searched 2026-10-27, read-only, including the finder's tool run; the independent reproduction's, the intended-behaviour check's and the adjudication's searches found nothing either): 264 logged queries (four runs of the scripted prior-report search over GitHub issues and PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, the JIRA archive and the web; 37 manual GitHub searches over apache/lucene, Elasticsearch, OpenSearch, Solr and Lucene.NET; 11 web searches), the file lists of all 409 open apache/lucene PRs with the patches of the 38 that touch IndexWriter, DocValuesFieldUpdates, ReadersAndUpdates, ReaderUtil, LeafReaderContext, Pending(Soft)Deletes or CheckIndex grepped, git history of IndexWriter.java and DocValuesFieldUpdates.java, and every list thread found; 22 hits read, none the same bug. Nearest items: #5275 (LUCENE-4203, added tryDeleteDocument and the assert-only check; no validation discussion), #9344 (LUCENE-8297, added tryUpdateDocValue; no validation discussion), open #7444 (LUCENE-6384, 'remove or fix tryDeleteDocument', about rare false returns), #5587, #6803, #6050, #9641 (other try* causes), open PR #16847 (jimczi, long doc ids: rewrites tryModifyDocument with an assert-only toLeafDocId and Math.toIntExact, so it neither reports nor fixes this), PR #16919 (romseygeek, merged 2026-10-28 as c5ab3e57b9b, where branch_11x was cut; AssertionErrors to exceptions in other files, no IndexWriter change), open PRs #16923 and #16810 (other IndexWriter methods). Refresh (2026-10-27, read-only): the 19 apache/lucene issues and PRs created since 2026-10-25 (#16905-#16923) are the same list the follow-up checked; #16919 and #16923 file lists do not touch tryModifyDocument; GitHub searches for tryUpdateDocValue, tryModifyDocument and open tryDeleteDocument items return only #9344 and #7444; #16847's last commit is still 2026-10-20 (some keyword searches hit the REST rate limit and were answered through GraphQL). Not fixed upstream.
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the one-test repro through gradle in a scratch worktree (assertions on and off; removed afterwards) and through the hunt repro harness (assertions on and off, 10 of 10 and 20 of 20 derived seeds); the fix through gradle (the repro and the 10 closest suites, 246 tests in 11 classes, assertions on and off; all of :lucene:core:test, 8,898 tests, assertions on); the study's independent 7-test class (assertions off: 7 of 7 fail; on: 2 of 7). The release jars 7.4.0 to 10.6.0 (68 releases) with the one-test repro, both modes; 7.3.1 (no API); the two LUCENE-8297 commits and their parents. The study ran on main a9779ef3c193 (the same lines). Upstream main 2f424fd428da (2026-10-30) has no commit to the involved files since 645d4fe14a20. A segment-size probe (leaf reader, id -1 and id maxDoc, 4 to 100 documents, assertions on and off) on main 645d4fe14a20. OpenJDK 25.0.3, Linux aarch64 (the only platform run).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1677-1688 (tryDeleteDocument javadoc: '> 0' means the delete succeeds), :1707-1721 (tryUpdateDocValue javadoc: '> 0' means the update succeeds; un-delete of a soft-deleted document), :1791-1827 (tryModifyDocument: the leaf-reader branch uses docID unchecked; the composite branch re-bases it with ReaderUtil.subIndex and only asserts at :1803-1804; returns docWriter.getNextSequenceNumber() at :1824)
  • lucene/core/src/java/org/apache/lucene/index/DocValuesFieldUpdates.java:253 (bitsPerValue = bitsRequired(maxDoc - 1) + SHIFT), :373 (only 'assert doc < maxDoc'), :384 (docs.set keeps only the bits that fit)
  • lucene/core/src/java/org/apache/lucene/index/BaseCompositeReader.java:260-266 (readerIndex throws IllegalArgumentException 'docID must be >= 0 and < maxDoc')
The finding in 30 seconds
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1791-1829 (tryModifyDocument), :1721-1784 (tryUpdateDocValue; buffer sized at :1740-1744, add at :1756-1757)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/DocValuesFieldUpdates.java:42 (SHIFT), :253 (bitsPerValue), :373 (assert doc < maxDoc), :384 (docs.set)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/ReaderUtil.java:74-94 (subIndex)
Who is affected
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1707-1720 (javadoc: un-delete use); a Lucene main checkout lucene/CHANGES.txt:7417-7421 (LUCENE-8297: soft-delete individual documents)
  • a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java:240 (the only main-code try* caller)
Why it looks unintended
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1707-1720 (javadoc), :1803-1804 (asserts), :1814-1819 (linear search), :2081-2093 (buildDocValuesUpdate field checks)
  • a Lucene main checkout lucene/CHANGES.txt:7417-7421 (LUCENE-8297)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/BaseCompositeReader.java:261-266; a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/CodecReader.java:93-101; a Lucene main checkout lucene/core/src/test/org/apache/lucene/index/TestDirectoryReader.java:1090-1097
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/document/column/ColumnValidation.java:297-307 (checkDocID)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/Bits.java:30-31; lucene/core/src/java/org/apache/lucene/index/DocValuesIterator.java:24-27; lucene/core/src/java/org/apache/lucene/index/IndexReader.java:51-52, :301-305

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

Repro files

About the diagrams

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.