All findings · Lucene finding · LFV-134 · updatedocvalues-registers-field-before-rejecting

A rejected IndexWriter#updateDocValues call still registers a never-seen field, so later legal documents with that field are refused

If IndexWriter#updateDocValues rejects an update of a never-seen field (SORTED doc values, which cannot be updated), it has still registered the field as doc-values-only, so the first legal document that indexes it is refused until the writer is reopened or emptied by deleteAll(). Default config; 9.0.0 to 10.6.0 and main.

Summary

Priority
Low, rank 842A 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
wrong error
Since
7.4.0 (narrow form, LUCENE-8316); 9.0.0 (full form, LUCENE-9334)introduced 2018-05-16 · about 8 years
Affected
7.4.0 to 10.6.0 (the latest release) and main
Status
documented
Found by
an Alloy model of the doc-values update argument checks (semi-formal)

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

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.

Background: how a field gets its schema

  1. An IndexWriter keeps 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 with cannot change field ... to inconsistent ....
  2. 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.
  3. 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.
Diagram of the bug: an empty IndexWriter receives updateDocValues for SortedDocValuesField tag, which is rejected because SORTED doc values cannot be updated, yet the field schema now holds tag as doc-values only (no terms); the next addDocument with StringField tag plus SortedDocValuesField tag is refused with: cannot change field tag from index options=NONE to inconsistent index options=DOCS; after the writer is reopened the same document is accepted.
A rejected doc-values update of the never-seen field 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.

The finding, step by step

  1. Open an IndexWriter with the default config on an empty directory. No document has a field tag yet.
  2. 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.
  3. 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.
  4. Instead addDocument throws cannot change field "tag" from index options=NONE to inconsistent index options=DOCS: the rejected call had registered tag as a doc-values-only field (no terms) before its type check threw.
  5. It lasts until the writer is closed and reopened (or deleteAll() empties the index), and getFieldNames() lists tag. The same happens through softUpdateDocument(s) and tryUpdateDocValue, and after a rejected update of an index-sort field price (then LongField("price", 3) is refused).

Who is affected

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.

The bug

  1. IndexWriter.buildDocValuesUpdate (IndexWriter.java:2081-2126) loops over the fields of the call. After the null and NONE checks (:2086-2093) it calls globalFieldNumberMap.verifyOrCreateDvOnlyField(f.name(), dvType, false) (:2097). For a name the writer has never seen, that creates a doc-values-only FieldInfo (index options NONE, no points, no skip index) and registers it with addOrGet(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").
  2. Only then come the checks that can still reject the call: cannot update docvalues field involved in the index sort (:2098-2104), and case 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.
  3. The exception leaves buildDocValuesUpdate before any update is queued (updateDocValues :2072; softUpdateDocuments :1673-1674 and softUpdateDocument :1946-1947 build the updates before the new documents are indexed; tryUpdateDocValue :1724), so no document changes. The call does not remove the registry entry.
  4. 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)).
  5. 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).

The numbers (executed on main, assertions on and off)

  • SORTED trigger, default config (the repro): after the rejected SortedDocValuesField("tag") update, StringField("tag") + SortedDocValuesField("tag") gets cannot change field "tag" from index options=NONE to inconsistent index options=DOCS. The same through softUpdateDocuments (with the soft-deletes field configured).
  • Index-sort trigger: index sorted on price; a rejected SortedNumericDocValuesField("price", 5) update, then LongField("price", 3) gets cannot change field "price" from points dimensionCount=0, indexDimensionCount=0, numBytes=0 to inconsistent dimensionCount=1, indexDimensionCount=1, numBytes=8, while a document with only SortedNumericDocValuesField("price"), the type of the rejected update, is accepted.
  • Index sort on rank (LONG): a rejected BINARY update of rank makes every later document that carries NumericDocValuesField("rank"), the type the sort requires, fail with from doc values type=BINARY to inconsistent doc values type=NUMERIC. Likewise a rejected NUMERIC update of the SORTED_NUMERIC sort field price makes documents with only SortedNumericDocValuesField("price") fail.
  • Two-field call (fresh price, then a SORTED tag): price stays registered and LongField("price") is refused.
  • tryUpdateDocValue (:1724) and softUpdateDocument: the same effect (executed by the independent fact check).

How the document path does it

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 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. 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 IllegalArgumentException until the writer is closed and reopened (or deleteAll() empties the index), and getFieldNames() 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, CheckIndex is 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).

Minimal repro

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.

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

import org.apache.lucene.document.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());
      }
    }
  }
}
Command
./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.

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

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.

Environment

lucenemain 645d4fe14a20 (gradle); also fails on the 10.6.0 and 10.5.2 jars
jdkOpenJDK 25.0.3
osLinux aarch64
Command (environment)
./gradlew :lucene:core:test --tests "org.apache.lucene.index.TestRejectedDocValuesUpdateRegistersField" -Ptests.seed=DEADBEEF -Ptests.asserts=true

Observed output

# 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 skipped

Why it looks unintended

  • addDocument checks a field's own index-sort type before registering it (IndexingChain.java:1382-1385 before :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.initializeFieldInfo checks the field's doc-values type against the index sort (IndexingChain.java:1382-1385) before fieldInfos.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 updateDocValues javadoc ("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 do softUpdateDocument(s) and tryUpdateDocValue. 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 the IllegalArgumentException (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.testBadDVUpdate asserts 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 registering addOrGet placed 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.

Suggested fix

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 synchronized method, 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 at IndexWriter.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:test with A and the new test, assertions on: 749 classes, 8,898 tests, 0 failures, 322 skipped. :lucene:core:check -x test passes.
  • 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 a as NUMERIC and as BINARY: stock rejects it and leaves getFieldNames() = [a]; A rejects it with the same message and leaves [].

B. The hunt's two-pass fix (validated by the checks)

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() is Set.copyOf of 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.

C. Move the registration below the checks inside the one loop (validated by the checks)

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.

D. Workaround for applications

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.

Downstream note

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.

How it was found

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 buildDocValuesUpdate into 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search2026-10-2616 GitHub issue/PR, 3 commit, 2 downstream, 52 mailing-list (dev@, java-user@, issues@, commits@), 12 JIRA and 10 web queriesno report; #8632 and #16565 as nearest items
GitHub GraphQL search2026-10-2675 queries: apache/lucene, the archived lucene-solr repository, Elasticsearch, OpenSearch, Solr, JIRA keys; method names and every error message involvedno report
JIRA REST2026-10-264 queries (exact error messages, updateDocValues + index options)LUCENE-9334 only
Open PRs2026-10-26changed files of all 413 open apache/lucene PRs; the 21 touching IndexWriter, FieldInfos or the update tests scanned, #16810 read in fullonly #16810 edits the guard; it keeps the order
GitHub (refresh)2026-10-315 searches on apache/lucene items created since 2026-10-25; #16810 state and diffnone; #16810 still open, order unchanged

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one-test repro fails (gradle; harness with assertions on and off)
10.6.0 latest releasethe one-test repro fails, assertions on and off
10.5.2 affectedthe 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.0history 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 formhistory 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 affectedhistory repro: 5 of 5 pass (an update needed an existing field and registered nothing)
fix A on 645d4fe14a20 not affectedthe repro passes; all of :lucene:core:test passes

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat 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 realOwn 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 severityRe-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 validatedGradle 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 correctedAn 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.

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

Finding record

LFV-134, snapshot of 2026-11-06

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:test with 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 main ea57360912ed (same IndexWriter.java and FieldInfos.java lines) with 20 history runs (16 on 14 releases from 7.0.0 to 10.5.2, 4 on main). Upstream main 2f424fd428da (2026-10-30) has no commit touching IndexWriter.java, FieldInfos.java or IndexingChain.java since 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: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, 2048
  • lucene/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-1393
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2059-2069 (at 645d4fe14a20) (updateDocValues javadoc), 2094-2104
  • lucene/MIGRATE.md:1559-1562; (at 645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestMixedDocValuesUpdates.java:695-728; TestIndexSorting.java:1988-2029
  • lucene/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

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.