buildDocValuesUpdate registers each never-seen field as doc-values-only (IndexWriter.java:2097) before the checks that can still reject the call: the index sort (:2098-2104) and SORTED / SORTED_SET (:2115-2124). The call does not undo it, so the writer's schema check refuses the next document that indexes the field differently.
- An
IndexWriterkeeps one schema per field name for the whole index: terms or not, points or not, which doc-values type. The first time it sees a field it records that schema; a later document that uses the field differently is refused withcannot change field ... to inconsistent .... - A doc-values update (
updateDocValues,softUpdateDocument(s),tryUpdateDocValue) may name a field that no document has yet. Since 7.4.0 the writer then creates the field (since 9.0.0 as a doc-values-only field: no terms, no points), so that an update that is applied defines the field. - Some updates are refused: NUMERIC, BINARY and SORTED_NUMERIC doc values can be updated, SORTED and SORTED_SET cannot, and a field of the index sort cannot be updated at all.
tag still writes tag into the writer's field schema as doc-values-only, so the next legal document with tag is refused until the writer is reopened. Select the still to open it at full size. Generated with AI, checked against the code.- Open an
IndexWriterwith the default config on an empty directory. No document has a fieldtagyet. - Call
updateDocValues(new Term("id", "0"), new SortedDocValuesField("tag", new BytesRef("a"))). It is rejected, correctly: SORTED doc values cannot be updated. No document changes. - You expect the writer to be unchanged, so the first document with
StringField("tag", "a", Store.NO)+SortedDocValuesField("tag", new BytesRef("a"))is accepted, as on a fresh writer. - Instead
addDocumentthrowscannot change field "tag" from index options=NONE to inconsistent index options=DOCS: the rejected call had registeredtagas a doc-values-only field (no terms) before its type check threw. - It lasts until the writer is closed and reopened (or
deleteAll()empties the index), andgetFieldNames()liststag. The same happens throughsoftUpdateDocument(s)andtryUpdateDocValue, and after a rejected update of an index-sort fieldprice(thenLongField("price", 3)is refused).
Applications that keep using an IndexWriter after it rejected one of their doc-values updates on a field it had never seen, for example a "try a doc-values update, fall back to a full update" layer, a pattern a committer suggested in 2017. Default config (the index-sort trigger needs an index sort). Loud, lasts until reopen, nothing reaches disk.
Mechanism, terms and numbers747 words
IndexWriter#updateDocValues, softUpdateDocument(s) and tryUpdateDocValue share buildDocValuesUpdate, which, for each field of the call, first registers a never-seen name as a doc-values-only field in the writer-wide field registry and only then runs the checks that can still reject the call: the field is part of the index sort, its type is SORTED or SORTED_SET (which cannot be updated), or a later field of the same call is bad. The call never undoes the registration; only reopening the writer (or deleteAll(), which empties the index) clears it. Until then, the first legal document that indexes that field with terms, points, a skip index or another doc-values type is refused with cannot change field ... to inconsistent ..., and getFieldNames() lists a field that no document or accepted update has. The rejected call changes no document, nothing reaches disk, the writer stays open, and a reopened writer accepts the document. The default IndexWriterConfig suffices (the index-sort trigger needs the opt-in index sort). It is in every release since 7.4.0 (LUCENE-8316), in the full form since 9.0.0 (LUCENE-9334), and unchanged in 10.6.0 and on main. An all-or-nothing fix (check every field first, then register all of them in one synchronized step) passes the repro and the whole core test suite.
IndexWriter.buildDocValuesUpdate(IndexWriter.java:2081-2126) loops over the fields of the call. After thenullandNONEchecks (:2086-2093) it callsglobalFieldNumberMap.verifyOrCreateDvOnlyField(f.name(), dvType, false)(:2097). For a name the writer has never seen, that creates a doc-values-onlyFieldInfo(index options NONE, no points, no skip index) and registers it withaddOrGet(fi)(FieldInfos.java:572-594; javadoc:550-561: "If the field doesn't exist and the parameter fieldMustExist is false, we create a new field in the global field numbers").- Only then come the checks that can still reject the call:
cannot update docvalues field involved in the index sort(:2098-2104), andcase NONE: case SORTED: case SORTED_SET: default:->can only update NUMERIC, BINARY or SORTED_NUMERIC fields(:2115-2124). In a multi-field call, any later bad field also throws after the earlier fresh fields were registered. - The exception leaves
buildDocValuesUpdatebefore any update is queued (updateDocValues:2072;softUpdateDocuments:1673-1674andsoftUpdateDocument:1946-1947build the updates before the new documents are indexed;tryUpdateDocValue:1724), so no document changes. The call does not remove the registry entry. - The next document with the field reaches
IndexingChain.initializeFieldInfo->FieldInfos.Builder.add(FieldInfos.java:788) ->FieldNumbers.addOrGet(:415-422) ->verifySameSchema(:511-515) and is refused (stack on main:FieldInfo.verifySameIndexOptions(FieldInfo.java:279)<-verifySameSchema(FieldInfos.java:515)<-addOrGet(:422)<-Builder.add(:788)<-IndexingChain.initializeFieldInfo(IndexingChain.java:1393)). - The registry is rebuilt from the segments when a writer opens, so closing and reopening the writer clears it. On the same writer only
deleteAll()clears it, because it drops every document and the whole registry (IndexWriter.java:2750,:2800; executed). A document that carries only the registered doc-values type is still accepted (executed).
- SORTED trigger, default config (the repro): after the rejected
SortedDocValuesField("tag")update,StringField("tag")+SortedDocValuesField("tag")getscannot change field "tag" from index options=NONE to inconsistent index options=DOCS. The same throughsoftUpdateDocuments(with the soft-deletes field configured). - Index-sort trigger: index sorted on
price; a rejectedSortedNumericDocValuesField("price", 5)update, thenLongField("price", 3)getscannot change field "price" from points dimensionCount=0, indexDimensionCount=0, numBytes=0 to inconsistent dimensionCount=1, indexDimensionCount=1, numBytes=8, while a document with onlySortedNumericDocValuesField("price"), the type of the rejected update, is accepted. - Index sort on
rank(LONG): a rejected BINARY update ofrankmakes every later document that carriesNumericDocValuesField("rank"), the type the sort requires, fail withfrom doc values type=BINARY to inconsistent doc values type=NUMERIC. Likewise a rejected NUMERIC update of the SORTED_NUMERIC sort fieldpricemakes documents with onlySortedNumericDocValuesField("price")fail. - Two-field call (fresh
price, then a SORTEDtag):pricestays registered andLongField("price")is refused. tryUpdateDocValue(:1724) andsoftUpdateDocument: the same effect (executed by the independent fact check).
For the same index-sort constraint, IndexingChain.initializeFieldInfo validates the field's doc-values type against the sort (IndexingChain.java:1382-1385) before it registers the field (:1392-1393): a rejected addDocument of a SORTED field on a sort field leaves getFieldNames() empty, and a later LongField document is accepted (an independent check's control). The single-field update methods updateNumericDocValue (IndexWriter.java:1964), updateBinaryDocValue (:2010) and updateSortedNumericDocValue (:2048) pass fieldMustExist=true and never register anything.
Exposure and likelihood336 words
- Who is exposed. Applications that keep using an
IndexWriterafter it rejected one of theirupdateDocValues,softUpdateDocument(s)ortryUpdateDocValuecalls withIllegalArgumentException, when that call named a field the writer had never seen. The update javadocs list neither the index-sort nor the type restriction, so such a call is an easy mistake (MIGRATE.md and CHANGES state only the broader rule that an updated field must be doc-values-only). - The natural path. In LUCENE-7659 (#8710, 2017) Adrien Grand suggested that applications "try the dv-only update and fallback to a regular update if it failed (which should be fine since dv updates are atomic)". For a never-seen field, the failed doc-values update now registers the field and the fallback document is refused.
- Default vs opt-in. The SORTED / SORTED_SET and multi-field triggers need only the default
IndexWriterConfig, codec and directory; the index-sort trigger needs the opt-in index sort. Assertions on or off. - What happens. Every later document that indexes the field with terms, points, a skip index or another doc-values type gets an
IllegalArgumentExceptionuntil the writer is closed and reopened (ordeleteAll()empties the index), andgetFieldNames()lists the field. With an index sort, a rejected update of the sort field that names another doc-values type than the sort uses stops every document that carries the sort field in the type the sort requires. - What does not happen. No corruption, no data loss, no wrong search result: the rejected call changes no document, the committed FieldInfos lack the field,
CheckIndexis clean, the writer stays open with no tragic event. - Scale and likelihood. One rejected call blocks each never-seen field it names, up to and including the one that fails, until the writer is reopened, on an index of any size. Rare: it needs an update the writer must reject, for a field it has never seen, and an application that keeps indexing that field. Solr, Elasticsearch and OpenSearch do not reach the rejecting branches on their default paths (read in their source, not run).
One JUnit test, public API, default IndexWriterConfig, ByteBuffersDirectory, single thread: a rejected SORTED update of tag, then one legal document with tag. Deterministic; it is also the suggested regression test.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.index;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.SortedDocValuesField;
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;
public class TestRejectedDocValuesUpdateRegistersField extends LuceneTestCase {
public void testRejectedUpdateDoesNotDefineField() throws Exception {
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
// SORTED doc values cannot be updated, so this call is rejected
expectThrows(
IllegalArgumentException.class,
() ->
w.updateDocValues(
new Term("id", "0"), new SortedDocValuesField("tag", new BytesRef("a"))));
Document doc = new Document();
doc.add(new StringField("tag", "a", Field.Store.NO));
doc.add(new SortedDocValuesField("tag", new BytesRef("a")));
try {
w.addDocument(doc);
} catch (IllegalArgumentException e) {
fail("expected: the first document with \"tag\" is accepted; actual: " + e.getMessage());
}
}
}
}
./gradlew :lucene:core:test --tests "org.apache.lucene.index.TestRejectedDocValuesUpdateRegistersField" -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
- Expected
- The first document with
StringField("tag")+SortedDocValuesField("tag")is accepted, as on a fresh writer. - Observed
cannot change field "tag" from index options=NONE to inconsistent index options=DOCS, with assertions on and off.
More files, environment, full output237 words
Copy the file to lucene/core/src/test/org/apache/lucene/index/ and run the command. The test makes one updateDocValues call that is correctly rejected (SORTED doc values cannot be updated) and then adds one document that a fresh writer accepts; it fails if that document is refused. It uses no randomness of its own; the only randomized input is the test framework's default codec, and the same failure was seen with three seeds and three codecs. Pass -Ptests.asserts explicitly, because by default the build picks it from the seed. The other triggers (an index-sort field, softUpdateDocument(s), a two-field call) have the same root cause and are described with their executed messages above; they are not extra repros.
| lucene | main 645d4fe14a20 (gradle); also fails on the 10.6.0 and 10.5.2 jars |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
./gradlew :lucene:core:test --tests "org.apache.lucene.index.TestRejectedDocValuesUpdateRegistersField" -Ptests.seed=DEADBEEF -Ptests.asserts=true
# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true
TestRejectedDocValuesUpdateRegistersField > testRejectedUpdateDoesNotDefineField FAILED
java.lang.AssertionError: expected: the first document with "tag" is accepted; actual: cannot change field "tag" from index options=NONE to inconsistent index options=DOCS
at __randomizedtesting.SeedInfo.seed([DEADBEEF:287DC055EC892FED]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.apache.lucene.index.TestRejectedDocValuesUpdateRegistersField.testRejectedUpdateDoesNotDefineField(TestRejectedDocValuesUpdateRegistersField.java:46)
1 test completed, 1 failed
# the same test with assertions off (gradle and the repro harness): the same message; on the 10.6.0 jars (on and off) and the 10.5.2 jars (off): the same message
# with the recommended fix, assertions on and off (the repro plus 8 closest suites):
:lucene:core:test (SUCCESS): 181 tests, 2 skippedaddDocumentchecks a field's own index-sort type before registering it (IndexingChain.java:1382-1385before:1393); the update registers first.- The update javadocs state neither restriction, and the writer stays open after the exception.
- Nothing documents that a rejected update defines a field; the order came from LUCENE-8316 inserting creation before older checks.
The full argument347 words
- The document path validates before it registers.
IndexingChain.initializeFieldInfochecks the field's doc-values type against the index sort (IndexingChain.java:1382-1385) beforefieldInfos.add(:1393); a rejected document of that kind leaves nothing behind (executed control). LUCENE-8316's code comment aims for "a consistent error message as if you try to do that during an indexing operation"; for this constraint the indexing operation leaves no state, the update does. - The javadocs do not state the restrictions the trigger breaks. The
updateDocValuesjavadoc ("Updates documents' DocValues fields to the given values ... All updates are atomically applied and flushed together",IndexWriter.java:2060-2062) states no index-sort or type restriction; neither dosoftUpdateDocument(s)andtryUpdateDocValue. MIGRATE.md (:1569-1574) and CHANGES document only that an updated field must be doc-values-only, which a never-seen field does not violate. The writer stays open after theIllegalArgumentException(no tragic event, executed), so it is meant to stay usable, and a call that did nothing then breaks the next legal call. - Nothing says a rejected call may define a field. No javadoc, test, CHANGES or MIGRATE entry, commit message or upstream discussion found says so (LUCENE-7581 #8632, LUCENE-8316 #9363, LUCENE-9334 PR #11, #16565, #16581, open #16810).
TestIndexSorting.testBadDVUpdateasserts only the rejection message. - An accident of insertion order. Until 7.3.1 the check was read-only (
if (!globalFieldNumberMap.contains(f.name(), dvType) ...) throw new IllegalArgumentException("can only update existing docvalues fields! ...")). LUCENE-8316 (0c3628920afd, 2018) turned it into a registeringaddOrGetplaced before the LUCENE-7581 index-sort guard (4efbde4e7627, 2016) and the type branch; rejected calls are not discussed. - Precedent. #7111 (LUCENE-6049, 2014): a flush failure caused by FieldInfos state that documents rejected with a non-aborting exception left behind was fixed as a bug (Michael McCandless: "this is a scary method and it has caused too many bugs over the years", about
FieldInfos.addOrUpdate). - Not the main contract: MIGRATE.md. "The per field data-structures are implicitly defined by the first document indexed that contains a certain field" (
MIGRATE.md:1559) is contradicted by design for accepted updates, which create fields without any document (LUCENE-8316,TestMixedDocValuesUpdates.testUpdateNotExistingFieldDV).
The devil's-advocate check218 words
The strongest case for "intended". IndexWriter documents no strong exception safety for its field registry. A rejected multi-field addDocument also leaves its earlier fresh fields registered, and that even persists across commit and reopen, because the rejected document stays as a deleted document (executed by independent checks). In the LUCENE-9334 review (apache/lucene-solr#2186) Mayya Sharipova asked on 2021-03-08 to confirm "A first doc with a field introduces FieldInfo for this field for the whole index, even if eventually this doc doesn't get indexed", and Adrien Grand replied on 2021-03-09 "These assumptions sound right to me." The javadoc's "atomically applied and flushed together" is about how accepted updates are applied, not a promise about the field registry after a rejection. Every trigger is a call that can never succeed, the error is loud, and integrators validate first (elastic/elasticsearch#157749 allows only fields outside the index sort).
Why it fails for the single-field cases. The analogue the committers accepted is a document that fails on a later field. For a field's own index-sort constraint the document path checks first and leaves nothing, and the update path does the reverse. The trigger is not a documented misuse, and the committer-recommended fallback pattern is exactly what breaks. Verdict: a low-severity bug; expect a maintainer to call the multi-field sub-case consistent with addDocument.
Recommended A. Check every field first, then register all of them in one synchronized step
Run the checks that need no registry (index sort, updatable type) for every field first, then verify the existing fields and create the missing ones in one synchronized, all-or-nothing FieldNumbers call (+39/-4 lines, two files). Validated: the repro and 8 closest suites pass with assertions on and off, and all of :lucene:core:test passes (8,898 tests).
All options and trade-offs4 options ยท 1,150 words
Options A and C were applied to 645d4fe14a20; A ran through gradle in a scratch worktree (removed afterwards) and, like B and C, through the repro harness with the patched classes shadowed. D needs no Lucene change.
A. Check every field first, then register all of them in one synchronized step (validated by the checks) (recommended)
buildDocValuesUpdate keeps the null, NONE, index-sort and type checks in its loop and builds the updates, but no longer registers anything there. After the loop, one new package-private FieldNumbers.verifyOrCreateDvOnlyFields(names, types) verifies every field that already exists (with fieldMustExist=true), rejects a call that names the same missing field with two types, and only then creates the missing fields, all under the FieldNumbers lock. +39/-4 lines in IndexWriter.java and FieldInfos.java.
diff --git a/lucene/core/src/java/org/apache/lucene/index/FieldInfos.java b/lucene/core/src/java/org/apache/lucene/index/FieldInfos.java
index 5bf4bbca524..dff8277da91 100644
--- a/lucene/core/src/java/org/apache/lucene/index/FieldInfos.java
+++ b/lucene/core/src/java/org/apache/lucene/index/FieldInfos.java
@@ -28,6 +28,7 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -687,6 +688,36 @@ public class FieldInfos implements Iterable<FieldInfo> {
isParentField);
}
+ /**
+ * Calls {@link #verifyOrCreateDvOnlyField} with {@code fieldMustExist=false} for all fields of
+ * one doc values update, all or nothing: it first verifies every field that already exists (and
+ * that a field missing so far is named with one doc values type only), and only then creates
+ * the missing fields, so that a rejected update leaves the field numbers unchanged.
+ */
+ synchronized void verifyOrCreateDvOnlyFields(String[] fieldNames, DocValuesType[] dvTypes) {
+ Map<String, DocValuesType> missing = new LinkedHashMap<>();
+ for (int i = 0; i < fieldNames.length; i++) {
+ if (fieldProperties.containsKey(fieldNames[i])) {
+ verifyOrCreateDvOnlyField(fieldNames[i], dvTypes[i], true);
+ } else {
+ DocValuesType previous = missing.putIfAbsent(fieldNames[i], dvTypes[i]);
+ if (previous != null && previous != dvTypes[i]) {
+ throw new IllegalArgumentException(
+ "Can't update ["
+ + dvTypes[i]
+ + "] doc values; the field ["
+ + fieldNames[i]
+ + "] has inconsistent doc values' type of ["
+ + previous
+ + "].");
+ }
+ }
+ }
+ for (Map.Entry<String, DocValuesType> e : missing.entrySet()) {
+ verifyOrCreateDvOnlyField(e.getKey(), e.getValue(), false);
+ }
+ }
+
synchronized Set<String> getFieldNames() {
return Set.copyOf(fieldProperties.keySet());
}
diff --git a/lucene/core/src/java/org/apache/lucene/index/IndexWriter.java b/lucene/core/src/java/org/apache/lucene/index/IndexWriter.java
index 45b59fa46dd..1414f8d0a45 100644
--- a/lucene/core/src/java/org/apache/lucene/index/IndexWriter.java
+++ b/lucene/core/src/java/org/apache/lucene/index/IndexWriter.java
@@ -2080,6 +2080,8 @@ public class IndexWriter
private DocValuesUpdate[] buildDocValuesUpdate(Term term, Field[] updates) {
DocValuesUpdate[] dvUpdates = new DocValuesUpdate[updates.length];
+ String[] fieldNames = new String[updates.length];
+ DocValuesType[] dvTypes = new DocValuesType[updates.length];
for (int i = 0; i < updates.length; i++) {
final Field f = updates[i];
final DocValuesType dvType = f.fieldType().docValuesType();
@@ -2091,10 +2093,6 @@ public class IndexWriter
throw new IllegalArgumentException(
"can only update NUMERIC or BINARY fields! field=" + f.name());
}
- // if this field doesn't exist we try to add it.
- // if it exists and the DV type doesn't match or it is not DV only field,
- // we will get an error.
- globalFieldNumberMap.verifyOrCreateDvOnlyField(f.name(), dvType, false);
if (config.getIndexSortFields().contains(f.name())) {
throw new IllegalArgumentException(
"cannot update docvalues field involved in the index sort, field="
@@ -2122,7 +2120,13 @@ public class IndexWriter
+ ", type="
+ dvType);
}
+ fieldNames[i] = f.name();
+ dvTypes[i] = dvType;
}
+ // Only once every field has passed the checks above: verify the fields that exist and create
+ // the missing ones as doc values only fields. This is all or nothing, so a rejected call leaves
+ // the global field schema unchanged.
+ globalFieldNumberMap.verifyOrCreateDvOnlyFields(fieldNames, dvTypes);
return dvUpdates;
}
Trade-offs
- A new package-private method in
FieldNumbers. Cost O(fields in the call) per call; it copies nothing. - Verification and creation run in one
synchronizedmethod, so no other thread can register a name in between (by reading the code; not stress-tested). - Error precedence changes: the index-sort and type checks of every field, and the construction of its update (e.g. the
(Long)cast atIndexWriter.java:2109), now run before the schema check of any existing field, so a call that breaks more than one rule, within one field or across fields, may report a different one of them (by reading; not an exhaustive list). No existing core test depends on that order. - Open PR #16810 edits the same lines (and keeps the old order), so one of the two needs a rebase.
What the checks validated
- Gradle in a scratch worktree of main
645d4fe14a20, seed DEADBEEF: the repro fails stock and passes with A. - The repro and 8 closest suites (
TestIndexSorting,TestMixedDocValuesUpdates,TestNumericDocValuesUpdates,TestBinaryDocValuesUpdates,TestSortedNumericDocValuesUpdates,TestSoftDeletesRetentionMergePolicy,TestSoftDeletesDirectoryReaderWrapper,TestFieldInfos): 181 tests, 0 failures, 2 skipped, with assertions on and off. - All of
:lucene:core:testwith A and the new test, assertions on: 749 classes, 8,898 tests, 0 failures, 322 skipped.:lucene:core:check -x testpasses. - Repro harness with A shadowed: the hunt's 4-test JUnit (index sort, SORTED,
softUpdateDocuments, two-field call) 4 of 4 pass (stock: 4 of 4 fail); the independent reproducer's 10 tests pass. - A probe of one call that names a fresh
aas NUMERIC and as BINARY: stock rejects it and leavesgetFieldNames() = [a]; A rejects it with the same message and leaves[].
Pass 1 takes globalFieldNumberMap.getFieldNames() and, per field, verifies existing names with fieldMustExist=true, then runs the index-sort and type checks; pass 2 is the old registration loop.
Trade-offs
- Not atomic: one call that names the same fresh field twice with conflicting types, or another thread registering the name between the passes, can still leave a partial registration (found by the cross-model reviews).
getFieldNames()isSet.copyOfof every field name (FieldInfos.java:690-691), on every update call, including the per-document soft updates of soft-deletes users (by reading; not measured).
What the checks validated
With the patched class shadowed: the hunt's 4-test JUnit 4 of 4, the independent reproducer's 10 of 10, 105 existing doc-values-update and soft-deletes tests and TestIndexSorting 62 of 62 (main ea57360912ed); the one-test repro with assertions on and off on 645d4fe14a20.
Move the verifyOrCreateDvOnlyField(f.name(), dvType, false) call (IndexWriter.java:2094-2097) below the switch: 4 lines moved, no new method.
Trade-offs
Fixes the single-field triggers only: in a multi-field call an earlier fresh field is still registered before a later field fails.
What the checks validated
With the patched class shadowed on 645d4fe14a20: the one-test repro passes; the hunt's 4-test JUnit gives 3 of 4, and the two-field call still fails. The one-test repro passes with A and with C alike, so it does not tell them apart.
Do not send doc-values updates for index-sort fields or SORTED / SORTED_SET fields (check the field type and IndexWriterConfig.getIndexSortFields() before the call), or close and reopen the writer after such an IllegalArgumentException.
Trade-offs
No Lucene change; every application has to know the rules the javadocs do not state.
Solr's in-place updates and the soft-deletes markers of Elasticsearch and OpenSearch do not reach the rejecting branches on their default code paths (read in their source, not run), so they are not exposed there; custom update layers that catch the exception and keep indexing are.
An Alloy 6.2 model of the doc-values update checks, with the property "a rejected call leaves the field schema unchanged". The order was spotted while transcribing the code; the solver then gave 2-step counterexamples (a rejected call registers a fresh index-sort field; a later document with a non-doc-values-only schema for it is refused), reduced to a public-API JUnit.
The method in full166 words
- Method (semi-formal): Alloy 6.2 model checking of the argument checks of the doc-values update entry points against the writer-wide field schema, in a hunt unit on SORTED_NUMERIC doc-values updates. Properties: exception safety ("a rejected call leaves the schema unchanged") and "a document is rejected only on a real conflict".
- Result. The finder spotted the order while transcribing
buildDocValuesUpdateinto the model; the solver then found 2-step counterexamples to both properties (a rejected two-field call that names a fresh index-sort field registers it; a later document that indexes the field with another, non-doc-values-only schema is then rejected). The two-pass fixed model has no counterexample up to 8 steps, and a reachability run shows that rejected updates and documents still occur in it (not vacuous). A first fixed variant, validating field by field inside the loop, was itself refuted by the solver for two-field calls. - Reduction. The finder turned the trace into a public-API JUnit on the stock jars; it was later minimized to the one test above.
No prior report found (searched 2026-10-26: 182 queries over GitHub, JIRA, four mailing lists, downstream repositories and the web, plus the changed files of all 413 open PRs; refreshed 2026-10-31). Closest: open PR #16810, which edits the same lines and keeps the order.
Searches5 searches
Read and not this bug: #16810 / #16809 (index-sort fields that existing segments are sorted by; same guard, different defect; the PR keeps verifyOrCreateDvOnlyField(..., false) before its new ensureNotIndexSortField, re-checked 2026-10-31); #16565 (added the missing index-sort guard to updateBinaryDocValue, placed before its type check; that method never registers); #16581 (SORTED_NUMERIC updates); #8632 (LUCENE-7581, the index-sort guard); #9363 (LUCENE-8316); #10374 / PR #11 / apache/lucene-solr#2186 (LUCENE-9334; the committer statement on failed first documents); #8710 (LUCENE-7659, the fallback pattern); #7111 (LUCENE-6049); #2835 (LUCENE-1761, field metadata is never removed); elastic/elasticsearch#157749 (open; allows only fields outside the index sort). No maintainer has triaged it. Private reports cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| scripted prior-report search | 2026-10-26 | 16 GitHub issue/PR, 3 commit, 2 downstream, 52 mailing-list (dev@, java-user@, issues@, commits@), 12 JIRA and 10 web queries | no report; #8632 and #16565 as nearest items |
| GitHub GraphQL search | 2026-10-26 | 75 queries: apache/lucene, the archived lucene-solr repository, Elasticsearch, OpenSearch, Solr, JIRA keys; method names and every error message involved | no report |
| JIRA REST | 2026-10-26 | 4 queries (exact error messages, updateDocValues + index options) | LUCENE-9334 only |
| Open PRs | 2026-10-26 | changed files of all 413 open apache/lucene PRs; the 21 touching IndexWriter, FieldInfos or the update tests scanned, #16810 read in full | only #16810 edits the guard; it keeps the order |
| GitHub (refresh) | 2026-10-31 | 5 searches on apache/lucene items created since 2026-10-25; #16810 state and diff | none; #16810 still open, order unchanged |
Introduced in 7.4.0 by LUCENE-8316 (0c3628920afd, 2018), which put field creation in front of the existing checks; widened in 9.0.0 by LUCENE-9334 (d03662c48bfc, 2021), which made per-field schemas strict. Executed: 7.3.1 clean, 7.4.0 to 8.11.4 narrow form, 9.0.0 to 10.6.0 and main full form; never fixed.
Versions tested7 rows
Introduced. LUCENE-8316 "Allow DV updates for not existing fields" (0c3628920afd, Simon Willnauer, 2018-05-16; a JIRA patch, migrated issue #9363; branch_7x backport fa00c9e55c16) replaced a read-only existence check that rejected unknown fields and left nothing behind with a registering addOrGet, placed before the LUCENE-7581 index-sort guard (4efbde4e7627, 2016) and the type branch. In 7.4.0 to 8.11.4 the registry recorded only the doc-values type for such an entry, so only documents with another doc-values type are refused (the narrow form; the history repro's equivalent of the one-test repro passes there, while the one-test repro itself needs the 9.1 test framework).
Widened. LUCENE-9334 "Consistency of field data structures" (d03662c48bfc, Mayya Sharipova, 2021-04-14, PR #11; first released in 9.0.0) made the call verifyOrCreateDvOnlyField(f.name(), dvType, false), which registers a complete doc-values-only schema, and made the registry check every later document's whole per-field schema, so terms and points documents are refused too.
Never fixed. The order is unchanged in 10.6.0 and on main; the only later edits are a reformat, a comment edit and the SORTED_NUMERIC case (#16581). Attribution rests on the two diffs and release-boundary runs; no commit-level bisect.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the one-test repro fails (gradle; harness with assertions on and off) |
| 10.6.0 | latest release | the one-test repro fails, assertions on and off |
| 10.5.2 | affected | the one-test repro fails (assertions off); the history repro fails 4 of 5 |
| 9.0.0, 9.1.0, 9.4.2, 9.12.3, 10.0.0, 10.3.2 | first: 9.0.0 | history repro: 4 of 5 fail (terms and points documents refused too); its fresh-writer control passes |
| 7.4.0, 7.7.3, 8.0.0, 8.11.4 | narrow form | history repro: 2 of 5 fail (only a document with another doc-values type is refused); the one-test repro's case passes |
| 7.0.0, 7.3.1 | not affected | history repro: 5 of 5 pass (an update needed an existing field and registered nothing) |
fix A on 645d4fe14a20 | not affected | the repro passes; all of :lucene:core:test passes |
A reviewer from another model family (twice), an independent reproducer, a devil's advocate and an adjudication all found it real; the adjudication confirmed it with a new seed. This doc re-ran the repro (gradle on 645d4fe14a20; the 10.6.0 and 10.5.2 jars) and validated the fix on the whole core test suite.
Checks and verdicts335 words
The hunt's checks ran on main ea57360912ed, whose IndexWriter.java and FieldInfos.java lines equal 645d4fe14a20.
| Check | Verdict | What it found |
|---|---|---|
| Cross-model review, refute lens | real (high) | An independent reviewer from another model family (OpenAI GPT-5.6): its own tests fail with assertions on and off on several seeds, fresh-writer controls pass; intended argument moderate; found the atomicity gaps of fix B. |
| Independent reproducer | real | Own 10-test JUnit written from the claim: 7 fail on main (on and off), 10.5.2, 9.12.3 and 9.0.0; 2 on 8.11.4 and 7.7.3 (narrow form); its 3 controls pass everywhere; 10 of 10 pass with the fix. |
| Cross-model review, works-as-designed lens | real (high) | The same reviewer family, executing: its own test reproduces both rejected-call cases, 5 controls pass; intended argument moderate (no general rollback guarantee; a failed addDocument leaves similar state). |
| Intended-behaviour devil's advocate | real (medium-high single-field, low-medium multi-field) | Built the works-as-designed case; controls C1-C5 (accepted updates create fields by design; the document path checks first; a rejected multi-field document also leaves fields; an existing field leaves nothing; getFieldNames() lists the field). |
| Adjudication | confirmed (medium), low severity | Re-ran with a new seed: the hunt's JUnit 4 of 4 fail; the independent reproduction's 7 of 10 fail with assertions off, controls pass; 0 of 10 with the fix. Labelled the class "wrong error". |
| This doc (2026-10-31) | reproduces; fix A validated | Gradle on 645d4fe14a20 (stock fails; fix A passes the repro, 8 closest suites and all of :lucene:core:test); the one-test repro on the 10.6.0 and 10.5.2 jars; fixes B and C compared. |
| Independent fact checks (2026-10-31) | holds; wording corrected | An independent fact check re-ran the repro (gradle and the harness, assertions on and off; the 10.6.0 and 10.5.2 jars), fix A on the whole core suite and a 9-test probe of every trigger; a reviewer from another model family (OpenAI GPT-5.6) checked the text. Both found the bug, its mechanism and fix A sound; their wording corrections 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 560 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,658 words
- Registry title
- A rejected IndexWriter#updateDocValues call (an index-sort field, SORTED or SORTED_SET doc values, or a bad later field in the same call) first registers a never-seen field as doc-values-only, so later legal documents that index that field are rejected until the writer is reopened
- Area
- index / IndexWriter doc-values updates and the writer-wide field registry (FieldInfos.FieldNumbers)
- Priority
- Low, rank 842: A rejected updateDocValues on a never-seen field makes later legal documents with that field fail until the writer is reopened; loud and limited to one writer session (the credibility audit downgraded it to wrong error).
- Impact class
- wrong error
- Impact note
- Applications that keep using an IndexWriter after it rejected one of their updateDocValues, softUpdateDocument(s) or tryUpdateDocValue calls with IllegalArgumentException, when that call named a field the writer had never seen (an index-sort field, a SORTED or SORTED_SET doc-values field, or a fresh field next to a bad one); the default IndexWriterConfig, codec and directory suffice (only the index-sort trigger needs the opt-in index sort), with -ea and -da; every later document that indexes that field with points, terms, a skip index or another doc-values type is refused with 'cannot change field ... to inconsistent ...' until the writer is closed and reopened, and getFieldNames() lists the field; the rejected call changes no document, nothing reaches disk and the writer stays open; ordinary scale; released 7.4.0-10.5.2 (before 9.0.0 only in a narrow form: only documents with another doc-values type are refused).
- Summary
- You call IndexWriter#updateDocValues (or softUpdateDocument(s) / tryUpdateDocValue) with a field that no document has used yet, and the call is correctly rejected with IllegalArgumentException, for example because the field is part of the index sort or because SORTED and SORTED_SET doc values cannot be updated. You expect the rejected call to leave the writer unchanged, so that the first real document with that field defines its schema, as on a fresh writer and as the document path does when it rejects a document for its index-sort doc-values type. Instead buildDocValuesUpdate has already registered the field as doc-values-only in the writer-wide FieldNumbers map before those checks run, and nothing undoes it: for the rest of the writer session a legal document such as LongField('price') or StringField('tag') plus SortedDocValuesField('tag') is refused ('cannot change field "price" from points dimensionCount=0 ... to inconsistent dimensionCount=1'), and getFieldNames() lists a field that no segment or accepted operation has.
- Introduced
- 0c3628920afd (LUCENE-8316 'Allow DV updates for not existing fields', by s1monw, a JIRA patch with no PR, migrated issue #9363; branch_7x backport fa00c9e55c16 plus compile fix 6b4daf8590b0), which replaced the read-only globalFieldNumberMap.contains() check (it threw 'can only update existing docvalues fields!' and left nothing behind) with a registering addOrGet placed before the LUCENE-7581 index-sort guard (4efbde4e7627, 2016) and before the unsupported-type branch; its message motivates creating the field for an update that is applied and says nothing about rejected calls; widened by d03662c48bfc (LUCENE-9334, PR #11, by mayya-sharipova, first released in 9.0.0), which turned the call into verifyOrCreateDvOnlyField(name, dvType, false), registering a complete doc-values-only FieldInfo, and made FieldNumbers check the whole per-field schema, so the leftover entry also rejects points and postings [read: the diffs of both commits in the full-history clone; exec: release-boundary runs, 7.3.1 not affected / 7.4.0 affected and 8.11.4 narrow / 9.0.0 full; no commit-level bisect, so other commits between those releases were not excluded by execution] (2018-05-16 (0c3628920afd, LUCENE-8316); widened 2021-04-14 (d03662c48bfc, LUCENE-9334))
- First affected version
- 7.4.0 [exec] (narrow form: the rejected call fixes only the field's doc-values type); 9.0.0 [exec] for the full form
- Affected versions
- 7.4.0-7.7.3 and 8.0.0-8.11.4, narrow form [exec on 7.4.0, 7.7.3, 8.0.0 and 8.11.4: the follow-up's history repro fails 2 of 5 (the two pins-doc-values-type tests), its two blocks-indexed-field tests and its fresh-writer control pass; the independent reproduction's repro fails 2 of 10 on 8.11.4 and 7.7.3]; 9.0.0-9.12.3, 10.0.0-10.5.2 and main ea57360912ed (unreleased 11.0.0), full form [exec: the history repro fails 4 of 5 on 9.0.0, 9.1.0, 9.4.2, 9.12.3, 10.0.0, 10.3.2, 10.5.2 and main (-ea; main also -da) with its control passing; the finder repro fails 4 of 4 on 9.8.0, 9.12.3, 10.5.2 and main (-ea and -da); the independent reproduction's repro fails 7 of 10 on 9.0.0, 9.12.3 (-ea, -da), 10.5.2 (-ea, -da) and main (-ea, -da)]; other releases in these ranges are inferred from unchanged code [read]; not affected: 7.0.0 and 7.3.1 [exec] (updateDocValues required an existing field and registered nothing); all four callers of buildDocValuesUpdate exist from 7.4.0 on, softUpdateDocuments was executed on 9.8.0 and later only and tryUpdateDocValue was read, not executed; not fixed upstream: the order is unchanged on main, branch_10x, branch_10_6 and branch_9_12 [read, not run], and open PR #16810, the only open PR that edits this guard, keeps it
- 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
- semi-formal: Alloy, code reading
- Upstream and prior reports
- none found (2026-10-26, the prior-report record, read-only: a scripted prior-report search with 16 GitHub issue/PR, 3 commit, 2 downstream-repo, 52 mailing-list (dev@, java-user@, issues@, commits@), 12 JIRA-archive and 10 web queries; 75 GitHub GraphQL issue/PR searches (47 on apache/lucene, 16 on Elasticsearch, OpenSearch and Solr, 6 on the archived lucene-solr repo, 6 JIRA-key lookups); 4 JIRA REST queries; the changed-file lists of all 413 open apache/lucene PRs, with the patches of the 21 that touch IndexWriter.java, FieldInfos.java, FieldInfo.java, a FieldInfos format or the doc-values-update tests scanned and #16810 read in full; 36 issues and PRs read with all comments; the code on main, branch_10x, branch_10_6 and branch_9_12; plus the finder's scripted prior-report search and the intended-behaviour check's searches); related prior items, none of them this bug: #16810 (open PR: folds the index-sort guards into one helper but keeps verifyOrCreateDvOnlyField(..., false) before it in buildDocValuesUpdate; a fix here would need a rebase), #16809 (open: index-sort fields are updatable when the writer config has no sort; same guard, different defect), #16565 (moved updateBinaryDocValue's index-sort check first for message clarity; that method never registers), #16581 (added SORTED_NUMERIC updates), #8632 (LUCENE-7581: origin of the index-sort guard), #9363 (LUCENE-8316: put field creation in front of the guard; rejected calls not discussed), #8710 (LUCENE-7659: a committer suggested 'try the dv-only update and fallback to a regular update if it failed', which this bug now breaks for a never-seen field), apache/lucene-solr#2186 and #10374 / #11 (LUCENE-9334: a committer agreed that a first document introduces a field's FieldInfo 'even if eventually this doc doesn't get indexed', the intent risk for the multi-field case), #7111 (LUCENE-6049: state left by rejected documents treated as a bug), #2835 (LUCENE-1761, open: field metadata is never removed), #15125 (cited by the intended-behaviour check: leftover state from a rejected document fixed as a bug, full rollback declined for cost); downstream elastic/elasticsearch#157749 (open) restricts updatable fields to fields outside the index sort and does not report this
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the one-test repro through gradle in a scratch worktree (assertions on; removed afterwards; an independent fact check repeated it with assertions on and off) and through the hunt repro harness (assertions on and off, three seeds); fix A through gradle (the repro and 8 closest suites with assertions on and off, all of:lucene:core:testwith assertions on,:lucene:core:check -x test); fixes A, B and C through the harness with the patched classes shadowed. The 10.6.0 jars (assertions on and off) and the 10.5.2 jars (off) with the one-test repro. The hunt ran on mainea57360912ed(sameIndexWriter.javaandFieldInfos.javalines) with 20 history runs (16 on 14 releases from 7.0.0 to 10.5.2, 4 on main). Upstream main2f424fd428da(2026-10-30) has no commit touchingIndexWriter.java,FieldInfos.javaorIndexingChain.javasince645d4fe14a20. OpenJDK 25.0.3, Linux aarch64 (the only platform run).
- Finding record
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2058-2079 (updateDocValues javadoc and body: 'All updates are atomically applied and flushed together'; no type or index-sort restriction stated), :2081-2126 (buildDocValuesUpdate: dvType==NONE check :2090-2093 before registration, verifyOrCreateDvOnlyField(f.name(), dvType, false) at :2097, index-sort IllegalArgumentException :2098-2104, SORTED/SORTED_SET IllegalArgumentException :2117-2124), :1664-1675 and :1938-1948 (softUpdateDocuments and softUpdateDocument build the updates before indexing), :1721-1724 (tryUpdateDocValue), :1962-1964, :1996-2010 and :2037-2048 (single-field siblings with fieldMustExist=true), :2135 (getFieldNames)lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:550-595 (verifyOrCreateDvOnlyField javadoc and body: 'If the field doesn't exist and the parameter fieldMustExist is false, we create a new field in the global field numbers', via addOrGet)lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1327-1330 and :1336-1338 (the document path validates the index-sort doc-values type before fieldInfos.add)lucene/MIGRATE.md:1557-1562 ('The per field data-structures are implicitly defined by the first document indexed that contains a certain field'; context only, not the main contract, see notes)SECURITY.md:28, 38, 46, 50 (cited by the security record)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2072, (at 645d4fe14a20) 2081-2126 (buildDocValuesUpdate: 2086-2093 null/NONE, 2097 registration, 2098-2104 index-sort IAE, 2115-2124 type IAE), 1673-1674, 1724, 1946-1947, 1964, 2010, 2048lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:415-422 (at 645d4fe14a20) (addOrGet), 511-515 (verifySameSchema), 550-561 and 562-594 (verifyOrCreateDvOnlyField), 745-788 (Builder.add)lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1372-1393 (at 645d4fe14a20) (initializeFieldInfo: 1382-1385 index-sort check before 1392-1393 fieldInfos.add)
- Who is affected
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1644-1663, (at 645d4fe14a20) 1707-1720, 1917-1937, 2059-2069 (the update javadocs)lucene/MIGRATE.md:1569-1574; (at 645d4fe14a20) lucene/CHANGES.txt:4740-4744 (doc-values updates only for doc-values-only fields)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1382-1385, (at 645d4fe14a20) 1392-1393lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2059-2069 (at 645d4fe14a20) (updateDocValues javadoc), 2094-2104lucene/MIGRATE.md:1559-1562; (at 645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestMixedDocValuesUpdates.java:695-728; TestIndexSorting.java:1988-2029lucene/MIGRATE.md:1569-1574; (at 645d4fe14a20) lucene/CHANGES.txt:4740-4744
- Suggested fix
lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:690-691 (at 645d4fe14a20) (getFieldNames copies the key set)
- Prior reports
gh search issues --repo apache/lucene --created >=2026-10-25 (updateDocValues, buildDocValuesUpdate, verifyOrCreateDvOnlyField, 'doc values update index sort', 'rejected update field'); gh pr view/diff 16810 (2026-10-31, read-only)
- History
a Lucene clone: git show releases/lucene/10.6.0:lucene/core/src/java/org/apache/lucene/index/IndexWriter.java (buildDocValuesUpdate unchanged); git log 645d4fe14a20..origin/main (2f424fd428da) on IndexWriter.java, FieldInfos.java, IndexingChain.java: no commits
- TestRejectedDocValuesUpdateRegistersField.java (one test):
repro/LFV-134/src/org/apache/lucene/index/TestRejectedDocValuesUpdateRegistersField.java(50 lines)
assets/LFV-134/bug.png(560 KB)
