All findings · Lucene finding · LFV-124 · addindexes-directory-registers-schema-before-failing

A failed addIndexes(Directory...) leaves the incoming field schemas in the writer, so later valid documents that use those names differently are rejected until it is reopened

When IndexWriter.addIndexes(Directory...) fails part-way, it deletes the files it copied but keeps the incoming fields registered with their schemas, so the same writer rejects valid documents that use those names differently until it is reopened. Every release since 4.2.0; nothing on disk is damaged.

Summary

Priority
Low, rank 840After a failed addIndexes(Directory...), leftover field registrations reject later documents that use those names differently until the writer is reopened, loud with no corruption, and it needs an app that keeps the writer after the failure.
Impact
crash
Since
4.2.0 (2013, the LUCENE-4547 doc-values work)introduced 2012-11-23 · over 13 years
Affected
every release 4.2.0 through 10.6.0, branch_11x and main
Status
documented
Found by
Alloy model of the writer's field registry (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

The writer keeps one in-memory list of field names and their schemas. addIndexes(Directory...) adds each incoming field to that list before it copies the field's segment; when the call fails later, it deletes the copied files but never takes the fields back off the list.

Three things to know first

  1. Every field name in an index has one schema: indexed or not (index options), doc values type, points, vectors. The first document indexed with a name fixes it; a later document that gives the name another schema is rejected with IllegalArgumentException.
  2. IndexWriter checks that against an in-memory registry of all names it knows, built from the segments when the writer opens. getFieldNames() returns the registry's names.
  3. addIndexes(Directory...) copies whole segments of other indexes into this one. Its javadoc promises that if an exception occurs, "either no indexes will have been added or they all will have been".
Diagram of the bug: a source index with the indexed fields a_new and z_conflict is added to a destination where z_conflict is doc values only; addIndexes(Directory...) throws and adds no document, but the writer's field registry now also holds a_new with index options DOCS, so a later document with NumericDocValuesField a_new is rejected; after a reopen, or with the fix, the registry holds only z_conflict and the document is accepted.
The writer's field registry before and after the failed addIndexes(Directory...): a_new stays registered with the incoming schema, so the later a_new document is rejected; after a reopen, or with the fix, it is accepted. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. The source index has one document with two indexed StringFields, a_new and z_conflict. The destination has one document where z_conflict is a NumericDocValuesField (doc values only). Default IndexWriterConfig.
  2. w.addIndexes(src) throws IllegalArgumentException ("cannot change field "z_conflict" from index options=NONE to inconsistent index options=DOCS"). That is correct: nothing is added, numDocs stays 1 and the writer stays open.
  3. You expect the writer to be as before: getFieldNames() = [z_conflict], and a_new still unused, so a document with NumericDocValuesField("a_new", 5) is accepted, as it is after a reopen.
  4. Instead getFieldNames() = [a_new, z_conflict] and that document is rejected: "cannot change field "a_new" from index options=DOCS to inconsistent index options=NONE". a_new was registered before z_conflict failed, and nothing takes it back.
  5. It lasts until the writer is reopened; the reopened writer accepts the same document. After commit no segment has a_new, and nothing of the failed import is visible.

Who is affected

Applications that bulk-import indexes with addIndexes(Directory...) and keep the writer after the call fails: a schema conflict found after a new field, a conflict between two incoming indexes, an index-sort mismatch, or an I/O error such as a full disk while copying. Default config. Later documents that use an imported name differently are rejected until the writer is reopened.

Mechanism, terms and numbers671 words

IndexWriter keeps one in-memory registry of every field name it knows and that field's schema (index options, doc values type, points, vectors), built from the segments when the writer opens, and checks every new document against it. addIndexes(Directory...) walks the incoming segments and, before copying each one, calls globalFieldNumberMap.addOrGet(fi) for every incoming field; addOrGet validates and registers. When the call then fails part-way (a schema conflict on a field visited later, a conflict between two incoming indexes, an index-sort mismatch on a later directory, or an IOException such as a full disk while copying a later segment), the failure path deletes the copied files and rethrows, but nothing removes the registrations. Nothing of the import is added or committed, as the javadoc promises, and the writer stays open; but for the rest of the writer session the incoming names keep the incoming schema: getFieldNames() lists a field that no segment has, and a later document that uses the name with another schema is rejected with IllegalArgumentException. Reopening the writer, or deleteAll(), clears it. The sibling addIndexes(CodecReader...) avoids this for conflicts with the destination's schema by checking every incoming field first, without registering. Every release from 4.2.0 through 10.6.0 is affected, and so are branch_11x and main. Checking up front and registering only when the copied segments are published fixes every executed trigger.

The bug (main 645d4fe14a20)

  1. The registry (FieldInfos.FieldNumbers) is built once, from the segments, when the writer opens: globalFieldNumberMap = getFieldNumberMap() (IndexWriter.java:1124), which calls addOrGet for every FieldInfo of every segment (:1267-1277).
  2. addIndexes(Directory...) (:3103) reads each directory's latest commit (:3126-3141). Inside the try at :3146, for each incoming segment it checks the index sort (:3152-3159), reads the segment's FieldInfos (:3177) and calls globalFieldNumberMap.addOrGet(fi) for every field, with the comment "This will throw exceptions if any of the incoming fields has an illegal schema change" (:3178-3182). Only then does it copy the segment (copySegmentAsIs, :3183).
  3. addOrGet (FieldInfos.java:415-457) checks a known name against its registered schema (:421-422) and registers an unknown name with the incoming schema (:423-454). In the example, a_new is field 0 of the incoming segment, so it is registered before z_conflict fails.
  4. The failure path (IndexWriter.java:3186-3192) deletes the files of the segments already copied and rethrows. The outer catch (:3212-3221) calls tragicEvent only for an Error, so after an IllegalArgumentException or an IOException the writer stays open. Nothing removes the registrations: FieldNumbers has no removal except clear() (FieldInfos.java:694-698), which only deleteAll() calls (IndexWriter.java:2800).
  5. A later document reaches FieldInfos.Builder.add (FieldInfos.java:788) -> FieldNumbers.addOrGet -> verifySameSchema (:511-548) and is rejected against the leftover schema. getFieldNames() (IndexWriter.java:2135-2137) returns the registry's names (FieldInfos.java:690-692).
  6. The sibling addIndexes(CodecReader...) runs "Best effort up front validations": the non-mutating verifyFieldInfo for every incoming field before any merge (IndexWriter.java:3288-3293, FieldInfos.java:401-408). With the same inputs it leaves the registry unchanged. verifyFieldInfo does not compare two incoming readers with each other; the tested conflict between two incoming readers is caught when their FieldInfos are merged and leaves no leftover either. It is not fully immune: an IOException during its merge leaves the same leftover, because the merge registers fields as it goes.
  7. Order matters: the leak needs at least one new incoming field visited before the failure. With the conflicting field first, nothing new is registered and nothing leaks.

What it looks like (diagnostic probe, main 645d4fe14a20)

stepstockwith the recommended fix
before the call: getFieldNames()[z_conflict][z_conflict]
addIndexes(src)IllegalArgumentException: cannot change field "z_conflict" from index options=NONE to inconsistent index options=DOCSthe same
after the call: getFieldNames(), numDocs[a_new, z_conflict], 1[z_conflict], 1
addDocument(NumericDocValuesField("a_new", 5))rejected: cannot change field "a_new" from index options=DOCS to inconsistent index options=NONEaccepted
after commit: fields of the committed segments[z_conflict][a_new, z_conflict] (the new document)
reopened writer: the same documentacceptedaccepted
Exposure and likelihood280 words
  • Who is hit. Applications that use addIndexes(Directory...), whose javadoc describes building sub-indexes in parallel and merging them with it (IndexWriter.java:3076-3079), and keep using the same writer after the call fails. Then every later document that gives one of the imported names a different schema is rejected.
  • Triggers that leak (executed on main): a schema conflict with the destination on a field visited after a new one; a conflict only between two incoming indexes; an index-sort mismatch on a later directory ("cannot change index sort from null to <long: "s">"); an IOException while copying a later segment, the javadoc's own "disk full" example ("simulated disk full while copying _1.cfs").
  • Default configuration. A default-constructed IndexWriterConfig (no custom codec or settings); ByteBuffersDirectory in the repro, FSDirectory in a reviewer's own test; with assertions on and off. The way in is an explicit bulk-import call that fails, followed by continued use of the writer.
  • Loud, no data damage. One IllegalArgumentException per affected addDocument; documents without the leftover names are unaffected, and a document that matches the leftover schema is accepted. The index on disk is sound: an NRT reader and the next commit show nothing of the failed import (no documents, stored values or FieldInfos), a pending delete stays applied, no unreferenced files remain, and CheckIndex is clean. An application that logs and drops failed documents loses them.
  • How long. Until the writer is reopened or deleteAll() is called. Other threads that use the same writer see the same rejections (one shared, synchronized registry; read from the code, not executed).
  • Scale. None: one document per index is enough.
  • Workaround. Close and reopen the writer after a failed addIndexes(Directory...).

Minimal repro

The test shows that a failed IndexWriter#addIndexes(Directory...) (schema conflict on "z_conflict", nothing added) leaves the incoming field "a_new", checked before the conflict, registered in the writer's schema (default config).

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

import java.util.List;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
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;

public class TestFailedAddIndexesRegistersField extends LuceneTestCase {

  public void testFailedAddIndexesDoesNotDefineField() throws Exception {
    try (Directory src = new ByteBuffersDirectory();
        Directory dst = new ByteBuffersDirectory()) {
      try (IndexWriter sw = new IndexWriter(src, new IndexWriterConfig())) {
        sw.addDocument(
            List.of(
                new StringField("a_new", "x", Field.Store.NO),
                new StringField("z_conflict", "x", Field.Store.NO)));
      }
      try (IndexWriter w = new IndexWriter(dst, new IndexWriterConfig())) {
        w.addDocument(List.of(new NumericDocValuesField("z_conflict", 1)));
        // "z_conflict" is doc-values only in dst, so nothing from src is added
        expectThrows(IllegalArgumentException.class, () -> w.addIndexes(src));

        try {
          w.addDocument(List.of(new NumericDocValuesField("a_new", 5)));
        } catch (IllegalArgumentException e) {
          fail("expected: the first document with \"a_new\" is accepted; actual: " + e.getMessage());
        }
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-124/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

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

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.index.TestFailedAddIndexesRegistersField
Expected vs actual
Expected the first document of the destination that uses "a_new" (NumericDocValuesField) to be accepted, as after a reopen; actual: IllegalArgumentException: cannot change field "a_new" from index options=DOCS to inconsistent index options=NONE, with -ea and -da.

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

More files, environment, full output48 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected: the first document with "a_new" is accepted; actual: cannot change field "a_new" from index options=DOCS to inconsistent index options=NONE
       org.junit.Assert.fail(Assert.java:89)
       org.apache.lucene.index.TestFailedAddIndexesRegistersField.testFailedAddIndexesDoesNotDefineField(TestFailedAddIndexesRegistersField.java:46)

Why it looks unintended

  • The javadoc promises that after an exception "either no indexes will have been added or they all will have been" (IndexWriter.java:3084-3086); its "disk full" example leaves the same leftover.
  • A field's schema is set by "the first document indexed" with it (MIGRATE.md); no a_new document was indexed.
  • addIndexes(CodecReader...) checks incoming fields first, without registering, and stays clean for this conflict.
The full argument299 words
  • Contract. addIndexes(Directory...) (IndexWriter.java:3084-3086): "This method is transactional in how Exceptions are handled: it does not commit a new segments_N file until all indexes are added. This means if an Exception occurs (for example disk full), then either no indexes will have been added or they all will have been." After the failed call, part of the incoming index still decides which documents the writer accepts, and the javadoc's own disk-full example leaks the same way.
  • Schema definition. MIGRATE.md:1557-1564: "The per field data-structures are implicitly defined by the first document indexed that contains a certain field." No document with a_new was ever indexed into the destination.
  • getFieldNames. "all field names as visible from this IndexWriter, across all segments of the index" (IndexWriter.java:2129-2133, @lucene.experimental); no segment has a_new.
  • The sibling overload validates first on purpose. addIndexes(CodecReader...) checks every incoming field with verifyFieldInfo before it changes anything (IndexWriter.java:3288-3293); the commit that added it, 698f40ad51af (LUCENE-10216, #633), lists "verify field info schemas upfront from incoming readers" and "Keep addIndexes API transactional". The Directory overload never got it.
  • Precedent. lucene/CHANGES.txt:20868-20873 (LUCENE-846): a failed addIndexes that could "corrupt the instance of IndexWriter (but, not the index itself)" was fixed as a bug. :21363-21370 (LUCENE-702) is the origin of the transactional contract ("either all indices are merged or none are"); the same change fixed IndexWriter.mergeSegments and IndexReader.commit() to "keep the instance state consistent to what's actually in the index".
  • Tests. TestIndexOptions.doTestChangeIndexOptionsAddIndexesDirectory (TestIndexOptions.java:125-159) asserts only the exception message, with one field, so nothing can leak there; no upstream test adds a document to, or checks the field names of, the writer after a failed addIndexes(Directory...) (TestIndexSorting and TestDocValuesIndexing.testMixedTypesViaAddIndexes only check that a second import of the same input also fails; TestIndexWriterOnDiskFull rolls the writer back).
The devil's-advocate check323 words

The devil's advocate built the strongest case for "accepted limitation"; the cross-model reviews had independently rated its core (the segments_N-only transactionality, the session registry and the experimental getFieldNames) moderate: the transactional sentence is literally about segments_N, and that part holds; the registry is a session registry that only grows by design (the deleteAll javadoc, IndexWriter.java:2735-2741, says only deleteAll clears "internal data structures" such as "doc value types"; #9597 calls field infos "(almost) append-only"); getFieldNames is experimental; a field whose documents are all deleted gives the same listing and rejection with no failure at all (the open #2835), and a rejected addDocument or a failed updateDocValues leaves similar state. In #13369 (the fix of #13353), for failed documents, benwtrent tried "Delaying on setting the field info until document writes are successful" (it "causes weirdness with global field numbers") and reverting the field info when all the docs for a field fail to index ("too sharp to mess with"), and settled on another fix.

Why it fails: no javadoc, test or discussion says that a failed operation may define a field's schema. In the deleted-field and rejected-document cases a document with the field reached the indexing chain; here none ever did. #13369 is about the per-document indexing chain, where delaying caused "weirdness with global field numbers"; here the copied segments keep their own field numbers (copySegmentAsIs copies the files unchanged, IndexWriter.java:3648-3705, and the result of addOrGet is unused, :3181), and the method has a documented all-or-nothing promise that its sibling already keeps by validating first. The trigger is legal public API with the default config, and the writer stays open with no tragic exception, so continuing to use it is not misuse. Our reading: a defect (the devil's advocate stayed at "uncertain"). Residual risk: a maintainer may treat it as one design question with the other entry points ("the registry is never rolled back after a failed operation").

Suggested fix

Recommended Check up front, register only when the segments are published (covers every executed trigger, validated)

Check every incoming field up front, as the CodecReader overload does, and register the fields only when the copied segments are published, all at once or not at all. +53/-6 in IndexWriter and FieldInfos; the repro and all 8,898 lucene/core tests pass, and the index-sort and disk-full cases no longer leak.

All options and trade-offs3 options ยท 690 words

Two fixes were built and validated with gradle; the recommended one covers every executed trigger, the smaller one only schema conflicts. A rollback alternative was not built.

Check up front, register only when the segments are published (covers every executed trigger, validated) (recommended)

Read every incoming segment's FieldInfos before copying anything and check them against the registry and against each other (a new non-mutating FieldNumbers.verifyFieldInfos); drop the addOrGet calls from the copy loop; and register all incoming fields with one synchronized FieldNumbers.addAll (verify all, then register all) inside synchronized (this), just before segmentInfos.addAll(infos). If that last call throws, the reserved document count is released and the copied files are deleted, as for any other failure there. +53/-6 in IndexWriter.java and FieldInfos.java. The core of it:

  // before the copy loop: check, register nothing
  List<FieldInfos> incomingFieldInfos = new ArrayList<>();
  for (SegmentInfos sis : commits) {
    for (SegmentCommitInfo info : sis) {
      incomingFieldInfos.add(readFieldInfos(info));
    }
  }
  globalFieldNumberMap.verifyFieldInfos(incomingFieldInfos);

  // synchronized (this), after reserveDocs(totalMaxDoc), before segmentInfos.addAll(infos)
  try {
    globalFieldNumberMap.addAll(incomingFieldInfos); // all or nothing
  } catch (Throwable t) {
    adjustPendingNumDocs(-totalMaxDoc);
    throw t;
  }

Trade-offs

  • The incoming FieldInfos are read once up front instead of inside the copy loop, and kept until publish (small).
  • A concurrent document that introduces one of the incoming names with another schema while the files are being copied is now accepted, and the addIndexes call then fails at publish (all or nothing, files deleted, reservation released); today that document is rejected instead. Either way one schema wins (reasoning; concurrency not executed).
  • Lock order is IndexWriter, then FieldNumbers, as elsewhere; no FieldNumbers method calls back into the writer.
  • addIndexes(CodecReader...)'s IOException case (its merge registers fields as it goes) is a separate path that this change does not touch.

What the checks validated

  • Gradle on 645d4fe14a20, fresh scratch worktree, seed DEADBEEF: stock, the test fails with assertions on and off. With the fix: the test plus TestAddIndexes, TestIndexOptions, TestConsistentFieldNumbers, TestFieldInfos, TestIndexSorting, TestIndexWriterOnDiskFull, TestDocValuesIndexing and TestPointValues, 9 suites, 194 tests, 0 failures; the test with assertions off passes; every lucene/core test: 749 suites, 8,898 tests, 0 failures, 322 skipped.
  • checkGoogleJavaFormat passes on the final diff and the test. The suites above ran with a first version that differs only in one reflowed javadoc line; with the final diff the test plus TestAddIndexes pass again (40 tests).
  • Standalone runner on the same commit with the patched classes: the test passes (-ea, -da); the independent reproducer's 5 tests go from 2 failures to 0; the devil's advocate's index-sort and disk-full probes no longer leak (getFieldNames() = [s] and [other], the a_new document accepted); the diagnostic probe shows [z_conflict] after the failed call.

Check up front only, as addIndexes(CodecReader...) does (partial, validated)

The hunt's fix, +16 in IndexWriter.java only: before the copy loop, check every incoming FieldInfo with globalFieldNumberMap.verifyFieldInfo and against the other incoming FieldInfos (FieldInfo.verifySameSchema); the loop keeps its addOrGet calls.

  Map<String, FieldInfo> incomingFields = new HashMap<>();
  for (SegmentInfos sis : commits) {
    for (SegmentCommitInfo info : sis) {
      for (FieldInfo fi : readFieldInfos(info)) {
        globalFieldNumberMap.verifyFieldInfo(fi);
        FieldInfo prev = incomingFields.putIfAbsent(fi.getName(), fi);
        if (prev != null) {
          prev.verifySameSchema(fi);
        }
      }
    }
  }

Trade-offs

Smallest change and mirrors the sibling overload, but it fixes the schema-conflict triggers only: an index-sort mismatch on a later directory and an IOException while copying a later segment (the javadoc's own "disk full" example) still leak with it.

What the checks validated

  • Gradle on 645d4fe14a20: the same 9 suites, 194 tests, 0 failures; checkGoogleJavaFormat passes.
  • Standalone runner: the test passes; the independent reproducer's 5 tests pass; earlier reviews: the refute-lens reviewer's own test 5/5 plus 6 existing addIndexes and schema tests, and TestIndexOptions 13/13.
  • The index-sort and disk-full probes still reject the a_new document with it.

Roll the registrations back on failure (not built)

Record the names this call newly registered and remove them in the catch.

Trade-offs

Needs a new removal in FieldNumbers, and another thread may start using a name between its registration and the rollback, so removal is unsafe without more bookkeeping; deferral avoids that.

Downstream note

Services that import index directories into a long-lived writer should reopen the writer after a failed import until this is fixed; nothing on disk needs repair.

How it was found

An Alloy model of the writer's field registry versus the segments' field lists. While writing the model's addIndexes(Directory...) step, code reading showed that the loop registers fields while it iterates; Alloy then produced a counterexample (a failed call leaves a new name registered) and none for the CodecReader overload. A JUnit on the stock jar followed.

The method in full132 words

Method: semi-formal (an Alloy 6.2 temporal model; the first signal was code reading while modelling, then witnessed by the tool).

  • The hunt unit modelled the writer-wide field registry (FieldNumbers) against the segments' FieldInfos, with contracts for schema agreement, transactional addIndexes and no phantom names (FieldNumbersTxn.als).
  • While writing the addIndexes(Directory...) action, the finder noticed that the loop registers each incoming field before copying its segment. Alloy produced a counterexample to AddIndexesTxnHolds (a failed addIndexes leaves a new name registered), while ReadersTxn, the CodecReader overload, had none in the same 6-step bound.
  • A JUnit on the stock jar confirmed it, later reduced to the one-test repro. The model has no I/O failures, so it says nothing about the IOException paths; those were found by the devil's advocate's probes.

Prior reports, history and checks

Prior reports

No prior report found: about 140 searches over GitHub issues and PRs, the JIRA archive, the mailing lists and the web (2026-10-26), re-checked on GitHub on 2026-10-31. Closest is the open #2835 (field metadata of fully deleted fields is never removed): the same "only grows" registry, but a different trigger and fix site.

Searches5 searches

The recorded searches found no report, discussion or fix of this bug. The adjudication notes that a maintainer could call it "the same limitation as LUCENE-1761" (#2835). Searches are term-based and best effort.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search (GitHub issues/PRs incl. the JIRA archive, commits, downstream, dev@ / java-user@ / issues@ / commits@, JIRA, web)2026-10-2645 GitHub issue/PR, 3 commit, 2 downstream, 40 mailing-list, 6 JIRA-archive and 8 web queriesno report
GitHub GraphQL: apache/lucene, Elasticsearch, OpenSearch, Solr2026-10-2636 searches with bodies read; full threads of more than 25 issues and PRs, 8 JIRA issues and 2 list threadsrelated items only
Open apache/lucene PRs2026-10-26changed-file lists of all 412 open PRs; the patches of the 19 that touch IndexWriter.java, FieldInfos.java, FieldInfo.java or TestAddIndexes.java greppednone changes the loop
GitHub issue/PR search and branch heads, apache/lucene2026-10-31addIndexes; getFieldNames; "cannot change field" addIndexes; addIndexes Directory schema failed; addIndexes transactional field; commits touching IndexWriter.java or FieldInfos.java on main since 2026-10-15only known items; no new commit
Related items (read)2026-10-26the threads of the closest items#2835 (LUCENE-1761, open, deleted fields' metadata never removed), #9597 (LUCENE-8551, won't fix, "(almost) append-only"), #13353 / #13369 (failed documents leave registered FieldInfo in the indexing chain; delaying or reverting tried, not adopted), #11253 / #633 / #1051 (LUCENE-10216, up-front check for the CodecReader overload), #11 / #10374 (LUCENE-9334), #1777 (LUCENE-702), #8710 / #9952 / #10719 (getFieldNames), #15683, #11554

History

Introduced in 4.2.0 (2013) by the LUCENE-4547 doc-values work (041ff1140f3f), which added both the schema check and the register-while-copying loop. Executed as failing on 4.2.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.5.2, 10.6.0 and main; 4.1.0 is not affected.

Versions tested8 rows

Introduced by 041ff1140f3f "make DV strongly typed; fix solr" (mikemccand, 2012-11-23, on the lucene4547 branch), which gave the registry a per-name doc-values type and, in the same change, added the register-while-iterating loop to addIndexes(Directory...) with a failure path that only deletes files. It reached trunk as a181dc073539 (2013-02-07) and branch_4x as 5cbaddcbc7ff (2013-02-08), both "LUCENE-4547: DocValues improvements"; no PR (Subversion era), and LUCENE-4547 (#5613) does not discuss addIndexes. Before it, addIndexes(Directory...) neither validated nor registered incoming fields.

Later changes kept the shape: d03662c48bfc (LUCENE-9334, PR #11, 2021) changed the call to addOrGet(fi) and widened the trigger to the whole per-field schema (the "This will throw exceptions ..." comment is older: e5c4ba48df7, LUCENE-6825, 2015, first released in 6.0.0); 0a4a76f0a869 (#14668, 2025) replaced the success flag with catch (Throwable), still deleting files only; 698f40ad51af (LUCENE-10216, #633) gave only the CodecReader overload an up-front check (from 9.4.0).

Trigger width: doc-values type conflicts from 4.2.0; point dimensions from 6.0.0 (read only); a change between two indexed index options from 8.0.0 (LUCENE-8134, read only); the whole per-field schema, including the repro's indexed versus doc-values-only case, from 9.0.0, so the repro's exact input is not a conflict on 8.11.4. The getFieldNames() symptom exists from 6.5.0 (LUCENE-7659). The index-sort and IOException triggers on old releases are from code reading only. The loop was read at every release tag of the history clone from 4.2.0 to 10.5.2; the clone has no 8.9.0-8.11.3 tags, so those releases were neither read nor run (they lie between the read 8.8.1 and the executed 8.11.4).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 (11.0.0-SNAPSHOT) affectedgradle: the one-test repro fails with assertions on and off; standalone too
10.6.0 affectedthe one-test repro fails on the released jars, with assertions on and off
main ea57360912ed, 10.5.2, 9.12.3 affectedthe hunt's and the independent reproducer's tests fail (the cross-model reviewers' on main too); the reopen and CodecReader controls pass
9.0.0 affectedthe hunt repro (ported import) and the doc-values-type repro fail
4.2.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4 doc-values triggera version-portable doc-values-type repro fails, each time with its reopen control passing; on 8.11.4 the repro's exact index-options input is not a conflict
4.1.0 not affectednot reachable: addIndexes(Directory...) neither validated nor registered incoming fields
branch_11x, branch_10_6, upstream main 2f424fd428da readthe same loop (IndexWriter.java byte-identical on branch_11x and main), read 2026-10-31
fix (645d4fe14a20 + register-at-publish) not affectedthe repro and all 8,898 lucene/core tests pass; index-sort and disk-full probes no longer leak

Independent checks

Confirmed by an independent reproducer, executing reviews from another model family (OpenAI GPT-5.6), a devil's advocate and an adjudication, each with their own tests and controls. This doc's test was re-run with gradle on main and with the standalone runner on the 10.6.0 jars, and the recommended fix was validated with gradle.

Checks and verdicts334 words

Every executing check wrote or ran its own test on the stock jars, with passing controls (no failed call; reopen; the CodecReader overload; conflict-first ordering). The reviews, the reproducer, the devil's advocate and the adjudication ran on the hunt's earlier main checkout ea57360912ed, where the cited files have the same lines.

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realOwn public-API test, conflict in the mirror direction: fails 2 of 5 on main (-ea, -da), 10.5.2 and 9.12.3; 20 of 50 with 10 iterations (deterministic); 3 controls pass; found that two incoming indexes that conflict only with each other leak too.
cross-model review, refute lens real, highAn independent reviewer from another model family (OpenAI GPT-5.6), executing: own FSDirectory test fails 2 of 5 (-ea, -da); controls pass (conflict-first ordering, CodecReader overload, reopen); the verify-first fix passes 5/5 plus 6 existing tests.
cross-model review, works-as-designed lens real, highSame reviewer family, executing: rated the "segments_N-only transactionality, session registry, experimental API" reading moderate and rejected it; own test confirms the leftover, the rejection, the clean reopen and the clean CodecReader overload.
devil's advocate uncertain (not not-real)"Neither intended nor documented, and it is not a misuse"; built the strongest accepted-limitation case; found the index-sort and disk-full triggers, the CodecReader IOException case, and that the verify-first fix is partial.
adjudication confirmed, mediumRe-ran the reproducer's test with a new seed: 2 of 5 fail (-ea, -da), controls pass; recorded the scope and partial-fix caveats.
Follow-up new; not security-sensitive18 history runs from 4.1.0 to main (plus one kept compile-error record); a fail-closed security characterization probe; the prior-art search.
this doc reproduces; fix validatedGradle on 645d4fe14a20 (assertions on and off) and the standalone runner on 10.6.0; the recommended fix with gradle (the repro, 9 suites, all of lucene/core, formatting) and the index-sort and disk-full probes; the smaller fix with gradle (9 suites).

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

Finding record

LFV-124, snapshot of 2026-11-06

Registry title
A failed addIndexes(Directory...) leaves the incoming fields registered in the writer's field schema, so getFieldNames() lists them and later valid documents that use those names differently are rejected until the writer is reopened
Area
index / IndexWriter addIndexes(Directory...) and the writer-wide field registry (FieldInfos.FieldNumbers)
Priority
Low, rank 840: After a failed addIndexes(Directory...), leftover field registrations reject later documents that use those names differently until the writer is reopened, loud with no corruption, and it needs an app that keeps the writer after the failure.
Impact class
crash
Impact note
Applications that call IndexWriter#addIndexes(Directory...) and keep using the same writer after the call fails part-way, once at least one new incoming field has been registered (a schema conflict with the destination or between the incoming indexes, an index-sort mismatch on a later directory, or an IOException such as a full disk while a later segment is copied); default IndexWriterConfig, codec and directory, with -ea and -da; every later document that gives one of those field names a schema other than the incoming one is rejected with IllegalArgumentException, and getFieldNames() lists the names although no segment has them, until the writer is reopened (or deleteAll is called); the index on disk is sound, nothing of the failed import becomes visible, documents without those names are unaffected, and an application that logs and drops failed documents loses them; ordinary scale; released 4.2.0-10.6.0.
Summary
You call IndexWriter#addIndexes(Directory...) with an index whose first field a_new is new to the destination and whose field z_conflict clashes with the destination's schema; the call correctly throws IllegalArgumentException and adds no segment, as its javadoc promises ('either no indexes will have been added or they all will have been'). You expect the writer to be as it was before the call, so that a later document can use a_new with any schema, as it can after the writer is reopened and after the same failure in addIndexes(CodecReader...). Instead the writer keeps a_new registered with the incoming schema, because addIndexes(Directory...) registers each incoming FieldInfo in the writer-wide FieldNumbers map while it iterates and its failure path only deletes the copied files: getFieldNames() lists a_new although no segment has it, and a document with NumericDocValuesField('a_new') is rejected ('cannot change field "a_new" from index options=DOCS to inconsistent index options=NONE') until the writer is reopened.
Introduced
041ff1140f3f ('make DV strongly typed; fix solr', by mikemccand on the lucene4547 development branch, svn r1412906), which gave FieldInfos.FieldNumbers a per-name doc-values type and added the loop in addIndexes(Directory...) that calls globalFieldNumberMap.addOrGet for every incoming FieldInfo before copySegmentAsIs, with a failure path that only deletes the copied files; it reached trunk as a181dc073539 and branch_4x as 5cbaddcbc7ff (both 'LUCENE-4547: DocValues improvements'); no PR (Subversion era), and LUCENE-4547 (GitHub #5613) does not discuss addIndexes or failure handling; d03662c48bfc (LUCENE-9334, PR #11) later changed the call to addOrGet(fi) with the comment 'This will throw exceptions if any of the incoming fields has an illegal schema change' and widened the trigger to every per-field attribute, and 0a4a76f0a869 (#14668) replaced the success flag with catch (Throwable) and still only deletes files [read: git blame of the loop and the diff of 041ff1140f3f in the full-history clone; the first parent of each landing commit has no addOrGet in addIndexes(Directory...); exec: not reachable on 4.1.0, fails on 4.2.0] (2012-11-23 (041ff1140f3f, lucene4547 branch); landed on trunk 2013-02-07 (a181dc073539) and branch_4x 2013-02-08 (5cbaddcbc7ff); 4.2.0 tagged 2013-03-11)
First affected version
4.2.0 [exec]
Affected versions
every release from 4.2.0 through 10.6.0 and main ea57360912ed and 645d4fe14a20 (unreleased 11.0.0) [exec: the one-test repro fails on the 10.6.0 release jars and on main 645d4fe14a20, -ea and -da (2026-10-31); the follow-up's version-portable doc-values-type repro fails on 4.2.0 (-ea and -da), 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main (-ea and -da), each time with its reopen control passing; the hunt repro fails on 9.0.0 (import-only port), 9.12.3, 10.5.2 and main (-ea and -da); the independent reproduction's own repro fails on main (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3; read: a scan of every 4.2.0..8.8.1 and 9.0.0..10.5.2 release tag finds the same register-while-iterating loop with no rollback and no up-front verify]; the hunt repro's exact trigger (an indexed StringField against a doc-values-only field) needs LUCENE-9334 and exists from 9.0.0 on (on 8.11.4 that input is not a conflict) [exec]; the getFieldNames() symptom exists from 6.5.0 (LUCENE-7659) [read; executed from 6.6.6 on]; the point-dimension (6.0+), index-sort and IOException triggers on old releases are from code reading only; 4.1.0 and earlier are not affected: addIndexes(Directory...) neither validated nor registered incoming fields [exec on 4.1.0]; not fixed upstream: the loop is unchanged on main, branch_10x, branch_10_6, branch_10_5 and branch_9_12 [read], IndexWriter.java is byte-identical at 645d4fe14a20 and upstream main 2f424fd428da [read 2026-10-31], and no open PR changes 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 45 GitHub issue/PR queries, 3 commit queries, 2 downstream-repo queries, 40 mailing-list queries over dev@, java-user@, issues@ and commits@, 6 JIRA-archive queries and 8 web queries; 36 GitHub GraphQL searches with bodies read, including Elasticsearch, OpenSearch, Solr; the changed-file lists of all 412 open apache/lucene PRs, with the patches of the 19 that touch IndexWriter.java, FieldInfos.java, FieldInfo.java or TestAddIndexes.java grepped; full-thread reads of 30+ issues and PRs, 8 JIRA issues and 2 list threads; the loop on main, branch_10x, branch_10_6, branch_10_5 and branch_9_12; plus the finder's scripted prior-report search and the intended-behaviour check's gh searches); related prior items, none of them this bug: #2835 (LUCENE-1761, open: field metadata of fully deleted fields is never removed; the same 'registry only grows' mechanism, but a persisted case with no failed call and a merge-side fix site), #9597 (LUCENE-8551, won't fix: field infos in the global map are '(almost) append-only'), #13353 / #13369 (a failed document leaves eagerly registered FieldInfo in the indexing chain; reverting registrations was considered and rejected there; not about addIndexes), #11554 (LUCENE-10518: partial FieldInfo after a non-aborting exception treated as a defect), #11253 / #633 / #1051 (LUCENE-10216: up-front verifyFieldInfo for addIndexes(CodecReader...) to keep it transactional; no mention of the Directory overload), #11 / #10374 (LUCENE-9334: strict per-field schema; rewrote the loop to addOrGet(fi)), #1777 (LUCENE-702: origin of the transactional addIndexes contract), #8710 / #9952 / #10719 (getFieldNames added, removed and re-added), #15683 (open: touches the addOrGet loop of getFieldNumberMap, not addIndexes); downstream sepinf-inc/IPED#1007 has the same error text from an application-side race, without addIndexes
Lucene commit tested
main 645d4fe14a20 (2026-10-28, 11.0.0-SNAPSHOT) with gradle in fresh scratch worktrees: the one-test repro fails with assertions on and off; with the recommended fix it passes, together with 9 addIndexes and field-schema suites (194 tests), every lucene/core test (8,898 tests, 0 failures) and the formatting check; the smaller fix passes the same 9 suites. The standalone JUnit runner on the same commit and on the released 10.6.0 jars (the repro fails, assertions on and off), and on the same commit with each fix shadowed: the repro, the independent reproducer's 5 tests, the devil's advocate's index-sort and disk-full probes and a diagnostic probe. The hunt's runs on main ea57360912ed (the cited files have the same lines), 10.5.2 and 9.12.3, and the follow-up's runs from 4.1.0 to 10.5.2. Read on 2026-10-31: IndexWriter.java is byte-identical at 645d4fe14a20, branch_11x and upstream main 2f424fd428da, and branch_10_6 has the same loop.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3084-3086 (addIndexes(Directory...) javadoc: 'This method is transactional in how Exceptions are handled ... either no indexes will have been added or they all will have been'), :3176-3182 (for each incoming FieldInfo: globalFieldNumberMap.addOrGet(fi), with the comment 'This will throw exceptions if any of the incoming fields has an illegal schema change', before copySegmentAsIs), :3186-3192 (catch (Throwable): deleteNewFiles for the copied segments, then rethrow; no registry rollback), :3288-3293 (addIndexes(CodecReader...): 'Best effort up front validations', globalFieldNumberMap.verifyFieldInfo for every incoming field), :2129-2137 (getFieldNames: 'all field names as visible from this IndexWriter, across all segments of the index', @lucene.experimental, returns globalFieldNumberMap.getFieldNames()), :2735-2741 (deleteAll javadoc: internal data structures such as doc value types are cleared, unlike a delete query), :1266-1277 (getFieldNumberMap builds the registry from the segments when a writer opens)
  • lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:401-407 (FieldNumbers.verifyFieldInfo, non-mutating), :415-457 (FieldNumbers.addOrGet validates and registers a new name), :694 (FieldNumbers.clear, the only removal)
  • lucene/MIGRATE.md:1557-1564 ('Require consistency between data-structures on a per-field basis': the per-field data-structures are implicitly defined by the first document indexed that contains a certain field)
  • lucene/CHANGES.txt:20835-20840 (LUCENE-846: a failed addIndexes that could corrupt 'the instance of IndexWriter (but, not the index itself)' was fixed as a bug) and :21330-21337 (LUCENE-702: addIndexes made transactional, and IndexWriter keeps 'the instance state consistent to what's actually in the index')
  • SECURITY.md:28-34, 42, 50 (index files and caller-supplied directories are trusted; in-scope reports; cited by the security record)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1124, 1267-1277, 2135-2137, 2800, 3103, 3126-3141, 3146-3192, 3212-3221, 3288-3293
  • lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:401-408, 415-457, 511-548, 690-698, 788
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3076-3079 (use case), 2735-2741 and 2800 (deleteAll clears the registry)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2129-2133, 2735-2741, 3084-3086, 3181, 3288-3293, 3648-3705
  • lucene/MIGRATE.md:1557-1564
  • lucene/CHANGES.txt:20868-20873 (LUCENE-846), 21363-21370 (LUCENE-702)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexOptions.java:125-159

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

Repro files

About the diagrams

A picture of the writer's field registry through the failed call, the rejected document and the reopen. The values come from executed probe runs on main 645d4fe14a20, stock and with a fix.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.