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.
- Inside a segment, documents are numbered 0 to
maxDoc - 1. ADirectoryReaderis a composite reader that numbers documents across its segments; each of its leaf readers is one segment. IndexWriter#tryUpdateDocValue(reader, docID, fields)(Expert) changes the doc values of the one document that has numberdocIDin an NRT reader of this writer. Writing the soft-deletes field this way soft-deletes that document.- 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.
- Index 4 documents with a numeric doc-values field
v= 0, 1, 2, 3 (default config, one segment) and open an NRT reader withDirectoryReader.open(writer). - 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. - 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. - 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.
- 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,
CheckIndexcalls it clean, andforceMerge(1)shifts the next segment's 10 values by one.
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.
// 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)- The buffer.
tryUpdateDocValuecreates aNumericDocValuesFieldUpdatessized for the segment'smaxDoc(IndexWriter.java:1740-1744) and adds the caller's leaf id unchanged (:1756-1757). - The width. Each entry has at least
bitsRequired(maxDoc - 1) + 1bits:PackedInts.DEFAULTlets 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), andPacked64.setdoes not mask, so only the low bits of that width survive. FormaxDoc4 the width is exactlybitsRequired(3) + 1= 2 + 1 = 3 bits.-1 << 1 | 1is all ones; its low 3 bits are0b111; shifted back, that is doc 3. - The guards. The leaf branch has none; the buffer's
assert doc < maxDocholds for -1. So the update is applied with assertions on or off, and the call returnsdocWriter.getNextSequenceNumber()(IndexWriter.java:1824): 7. - Same arithmetic, other ids (assertions off, composite reader, where only the asserts at
:1803-1804stand in the way):maxDoc8 gives 4 bits, so 8 -> doc 0, 9 -> doc 1 andNO_MORE_DOCS(2^31 - 1) -> doc 7.maxDoc10 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. - Other segment sizes (leaf reader, id -1, assertions on and off): -1 lands on the last document only when the width is exact and
maxDocis 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. - After the commit the doc-12 entry is an extra
FieldExistsQueryhit, and stored fields reject doc 12;CheckIndexdoes not flag that commit on any version tested, andforceMerge(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) | assertions | returns | what changes |
|---|---|---|---|
tryUpdateDocValue(4-doc leaf, -1, v=777) | on and off | 8 | doc 3: 1003 -> 777 |
tryUpdateDocValue(4-doc leaf, -1, soft=1) | on and off | 8 | doc 3 soft-deleted |
tryUpdateDocValue(8-doc reader, 8, v=777) | off | 12 | doc 0: 1000 -> 777 |
tryUpdateDocValue(8-doc reader, 9, soft=1) | off | 12 | doc 1 soft-deleted (live docs 8 -> 7) |
tryUpdateDocValue(8-doc reader, NO_MORE_DOCS, v=777) | off | 12 | doc 7: 1007 -> 777 |
tryUpdateDocValue(10-doc reader, 12, v=777), commit, a second 10-doc segment, forceMerge(1) | off | 14 | commit 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 off | throws IndexOutOfBoundsException | nothing |
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 orNO_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
IndexWriterConfigand 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-eatest 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,
CheckIndexcalls 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) callstryDeleteDocumentwith ids from a loop overmaxDoc. No memory corruption: the wrong data goes through ordinary Java arrays. - Impact class: data corruption, likelihood contrived (needs a caller bug); priority low.
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.
/*
* 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());
}
}
}
}
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, ...)throwsIllegalArgumentExceptionor 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>.
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.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 |
# 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)- 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 >= 0anddocID < reader.maxDoc()since LUCENE-4203 (2012), and the bufferdoc < maxDocsince LUCENE-7868 (2017). WhentryDeleteDocumentwas added (4.0), its delete path also asserted the range (ReadersAndLiveDocs.delete), on the leaf branch too; with assertions off, the 4.0BitVectorthrewArrayIndexOutOfBoundsExceptionfor most bad ids, though ids betweenmaxDocand the next multiple of 8 returnedtrueand 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-266throwsIllegalArgumentException"docID must be >= 0 and < maxDoc";CodecReader.java:93-101checks withObjects.checkIndexunder the comment "Don't trust the codec to do proper checks";TestDirectoryReader.testOOBDocID(:1090-1097) expects the exception.IndexWriter#addBatchchecks 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
synchronizedand 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:
- 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. - The damage is not confined to the caller's bad input: a different, valid document changes, under a success return value.
- The writer commits an internally inconsistent index (a doc-values entry beyond
maxDoc) thatCheckIndexaccepts and a merge spreads onto other documents. - The leaf branch has not even an assert, so Lucene's own
-eatest runs cannot catch the negative-id case. - The same reader, the same call and
addBatchreject bad input with exceptions; the merged #16919 (2026-10-28) turned more explicitAssertionErrors 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.
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=trueandfalse: 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/miscTestIndexRearranger(the in-treetryDeleteDocumentcaller): 246 tests in 11 classes, 9 skipped, 0 failures each time. - All of
:lucene:core:testwith 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.
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.
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.
Document that the id must be in [0, reader.maxDoc()).
Trade-offs
Leaves silent success with a different document changed; not recommended.
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.
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
tryDeleteDocumentandtryUpdateDocValueagainst flush, merge and commit. - Signal. While writing that unit's oracle "returns > 0 and changes a different document", the finder noticed that
tryModifyDocumentonly 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), downstream repositories, JIRA archive, mailing lists, web | 2026-10-27 | 264 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 grepped | 22 hits read, none this bug |
| Refresh: GitHub, open PRs, mailing lists, JIRA archive, web | 2026-10-31 | 232 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 comments | nothing new; #16847 still open, unchanged since 2026-10-20 |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the one test fails (gradle and harness, -ea and -da) |
| 10.6.0 | affected | the one test fails (-ea and -da) |
| 7.4.0 to 10.5.2 (67 releases) | affected | the 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 runs | both LUCENE-8297 commits fail (returned 8, doc 3 = 777); the parents lack the API |
| 7.3.1 | API absent | no tryUpdateDocValue: the test does not compile |
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).
| Check | Verdict | What it found |
|---|---|---|
| independent reproducer | real | Own 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, medium | Built 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 only | Read-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, medium | Re-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 | keep | Fresh seeds: 7 of 7 fail with assertions off, 2 of 7 with them on. |
| current-main re-check | real | The 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-high | The 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 applied | An 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. |
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 544 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,467 words
- 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 maina9779ef3c193(the same lines). Upstream main2f424fd428da(2026-10-30) has no commit to the involved files since645d4fe14a20. A segment-size probe (leaf reader, id -1 and idmaxDoc, 4 to 100 documents, assertions on and off) on main645d4fe14a20. OpenJDK 25.0.3, Linux aarch64 (the only platform run).
- 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-1097a 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
- TestTryUpdateDocValueNegativeDocId.java (one test):
repro/LFV-802/src/org/apache/lucene/index/TestTryUpdateDocValueNegativeDocId.java(52 lines)
assets/LFV-802/bug.png(544 KB)
