All findings · Lucene finding · LFV-618 · multipass-splitter-bare-config-rejects-softdeletes-parent

MultiPassIndexSplitter cannot split an index that has a soft-deletes field or a parent field

MultiPassIndexSplitter opens every part's writer with a bare IndexWriterConfig and takes no config, so addIndexes rejects any index whose field infos mark a soft-deletes field (since 7.4.0) or a parent field (since 9.10.0): the split throws IllegalArgumentException before any part gets a document. Two documents and one soft update are enough.

Summary

Priority
Low, rank 668A little-used misc tool fails loudly every time on soft-deletes or parent-field indexes without damaging anything.
Impact
minor
Since
7.4.0 for soft deletes (LUCENE-8335, 2018); 9.10.0 for the parent field (#12829, 2024)introduced 2018-06-04 · about 8 years
Affected
every release from 7.4.0 to 10.6.0 and main (parent field: from 9.10.0)
Status
documented
Found by
randomized round-trip testing of the index tools, then a public-API JUnit

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Each part's writer gets new IndexWriterConfig(null): no soft-deletes field, no parent field, and split() has no way to pass one. addIndexes checks the input's fields first and refuses the marked field, so the split stops before copying anything.

Background

  1. The tool. MultiPassIndexSplitter cuts one index into N parts. For each part it hides the other parts' documents and copies the rest into a new writer with IndexWriter.addIndexes.
  2. Soft deletes. With IndexWriterConfig#setSoftDeletesField("soft"), softUpdateDocument marks the old copy deleted through a doc-values field instead of deleting it. The index's field infos record that soft is the soft-deletes field.
  3. The check. Since 7.4.0 a writer refuses to take in a field marked as soft-deletes field unless its own config names the same field. Since 9.10.0 the same holds for the parent field set with setParentField.
MultiPassIndexSplitter splits an index with 2 live documents, a and b, whose field infos mark soft as the soft-deletes field. Each part's writer is opened with new IndexWriterConfig(null), which names no soft-deletes field, so IndexWriter.addIndexes throws IllegalArgumentException: this index has [soft] as soft-deletes already but soft-deletes field is not configured in IWC, and no part gets a document. With the fix each part's config names soft, and part 0 gets a, part 1 gets b.
Each part's writer gets a config that names no soft-deletes field, so the input's soft field is refused before anything is copied; with the fix each part's config names soft. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You index a and b with setSoftDeletesField("soft"), then replace b with softUpdateDocument. The index now has 2 live documents.
  2. You split it into 2 parts with new MultiPassIndexSplitter().split(reader, parts, true) and expect a in one part and b in the other: split() says the source "can have deletions".
  3. Instead it throws IllegalArgumentException: this index has [soft] as soft-deletes already but soft-deletes field is not configured in IWC, with assertions on and off, and nothing is split.
  4. Why: each part's writer gets new IndexWriterConfig(null), which names no soft-deletes field, and you cannot pass one. Since 7.4.0 addIndexes refuses a soft-deletes field the writer does not name, since 9.10.0 also a parent field: a 12-document index with one block under setParentField fails the same way.
  5. The source is only read and no part gets a document. Giving each part the input's two field names fixes it.

Who is affected

Users of the misc MultiPassIndexSplitter, by command line or split(), on an index whose segments carry a soft-deletes field (7.4.0+) or has a parent field (9.10.0+; required since 10.0 for index sorting with blocks). Every split fails at once, at any size. Nothing is damaged; PKIndexSplitter with configs is the workaround.

Mechanism, terms and numbers666 words

MultiPassIndexSplitter (lucene/misc) splits an index into N parts by copying the input's segments, with the other parts' documents hidden, into one new IndexWriter per part. Every part's writer is opened with new IndexWriterConfig(null).setOpenMode(OpenMode.CREATE), and split(IndexReader, Directory[], boolean), the only API, takes no config. Since LUCENE-8335 (7.4.0) IndexWriter.addIndexes rejects an incoming field marked as the soft-deletes field unless the writer's config names the same field, and since #12829 (9.10.0) the same holds for the parent field. So the splitter throws IllegalArgumentException on every index whose segments still mark a soft-deletes field (in practice any index with a soft update or soft delete in a surviving segment; the mark survives merges), and on every index written with setParentField, in both split modes and from its command line. It fails before any part gets a document; the source is only read. No release has been able to split an index whose segments carry the soft-deletes mark: 7.4.0 shipped soft deletes and the check together. Configuring each part with the input's two field names fixes it.

The code (main 645d4fe14a20)

  1. The split. split() (MultiPassIndexSplitter.java:70) wraps the input in a private FakeDeleteIndexReader (:81), and for each part marks every document outside the part as deleted (:84-107), opens the part's writer with new IndexWriterConfig(null).setOpenMode(OpenMode.CREATE) (:108-109) and copies the leaves with w.addIndexes(...) (:113). The command line main() opens a plain DirectoryReader (:157) and calls split() (:182); run on such an index it throws the same exception and exits with code 1 (executed).
  2. The writer's field roles. The writer builds its field-number map from its config: new FieldNumbers(config.getSoftDeletesField(), config.getParentField()) (IndexWriter.java:1267-1269), so here both are null.
  3. The check. addIndexes(CodecReader...) runs "Best effort up front validations" over every incoming field (IndexWriter.java:3288-3295, verifyFieldInfo at :3292). verifySoftDeletedFieldName throws "this index has [soft] as soft-deletes already but soft-deletes field is not configured in IWC" (FieldInfos.java:459-465); verifyParentFieldName throws "can't add field [_parent] as parent document field; this IndexWriter has no parent document field configured" (FieldInfos.java:484-490).
  4. What carries the mark. A segment's field infos mark the soft-deletes field once a soft update or soft delete wrote it. In the minimal test the writer has already merged the soft-deleted old b away when it closes: the index is one segment from a merge with 2 documents, both live, and none has a value in soft, yet the mark alone is rejected. The mark is per segment: once every segment that carried it has been dropped as fully deleted, the index has no soft field info and splits (executed). A configured but never-written soft-deletes field splits fine. A parent field is added to every index written with setParentField.
  5. No supported way around it. The config is built inline and the reader wrapper is private; the command line has no option for these settings.
  6. Side effect. The exception leaves the first part's writer open with its write lock (a separate registry row, LFV-554).

What happens (stock main, assertions on and off)

you splityou get
2 live docs after addDocument(a), addDocument(b), softUpdateDocument(b) under setSoftDeletesField("soft"), read through SoftDeletesDirectoryReaderWrapper, 2 partsIllegalArgumentException: this index has [soft] as soft-deletes already but soft-deletes field is not configured in IWC
the same index read with a plain DirectoryReader, as main() doesthe same exception
10 documents and one 2-document block under setParentField("_parent")IllegalArgumentException: can't add field [_parent] as parent document field; this IndexWriter has no parent document field configured
an index-sorted block index with the parent field that MIGRATE.md requires since 10.0the same parent-field exception
controls: a plain index with a hard delete; a soft-deletes field configured but never writtensplit correctly
an index whose only soft-updated segment was dropped as fully deleted (addDocument(a), commit(), softUpdateDocument(a), addDocument(b); no soft field info left)splits correctly
PKIndexSplitter with its IndexWriterConfig constructors and setSoftDeletesFieldsplits the soft-deletes index correctly (the workaround)
Exposure and likelihood210 words
  • Who. Users of MultiPassIndexSplitter (its command line or split()) on an index whose segments carry a soft-deletes field (7.4.0 and later; at least one soft update or soft delete in a surviving segment, whose mark survives merges) or has a parent field (9.10.0 and later). Both settings are opt-in, but MIGRATE.md requires the parent field since 10.0 for index sorting combined with document blocks. A default index is not affected.
  • Effect. Every split of such an index fails at once, in both split modes, with assertions on and off, at any size and with no timing condition. No release has been able to split an index whose segments carry the soft-deletes mark, because 7.4.0 shipped soft deletes and the check together.
  • Workarounds. None supported through this API. PKIndexSplitter with its config constructors, or addIndexes into writers configured with the same field, do work.
  • Not affected. The source index (only read). The parts get no document and no commit; on a file-system directory the first part keeps only its write.lock (LFV-554). It is not data loss or corruption.
  • How likely: plausible. Deterministic for anyone who runs the tool on such an index, but the tool is little used, and no report was found in the searched sources (see Prior reports).

Minimal repro

One JUnit test, one thread, deterministic, public API: write two documents and one soft update, then split the 2 live documents into 2 in-memory parts. It only checks that split() does not throw.

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

public class TestMultiPassIndexSplitterSoftDeletes extends LuceneTestCase {

  public void testSplitIndexWithSoftDeletes() throws Exception {
    Directory dir = new ByteBuffersDirectory();
    try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setSoftDeletesField("soft"))) {
      w.addDocument(List.of(new StringField("id", "a", Field.Store.NO)));
      w.addDocument(List.of(new StringField("id", "b", Field.Store.NO)));
      w.softUpdateDocument(
          new Term("id", "b"),
          List.of(new StringField("id", "b", Field.Store.NO)),
          new NumericDocValuesField("soft", 1));
    }
    Directory[] parts = {new ByteBuffersDirectory(), new ByteBuffersDirectory()};
    try (DirectoryReader reader =
        new SoftDeletesDirectoryReaderWrapper(DirectoryReader.open(dir), "soft")) {
      new MultiPassIndexSplitter().split(reader, parts, true);
    } catch (IllegalArgumentException e) {
      throw new AssertionError("expected the 2 live docs split into 2 parts, got: " + e, e);
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:misc:test --tests TestMultiPassIndexSplitterSoftDeletes -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
split() returns and the 2 live documents end up in the 2 parts (a in one, b in the other).
Observed
IllegalArgumentException: this index has [soft] as soft-deletes already but soft-deletes field is not configured in IWC, with assertions on and off.

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

More files, environment, full output283 words

Two documents and one softUpdateDocument under setSoftDeletesField("soft"), read through SoftDeletesDirectoryReaderWrapper: 2 live documents, the minimum split() accepts, split sequentially into 2 ByteBuffersDirectory parts. The test sits in org.apache.lucene.misc.index for locality and uses no package-private API. ByteBuffersDirectory keeps the first part's leaked writer (LFV-554) from tripping the test framework's open-file check. The test body makes no random choices; the test framework still randomizes its usual environment (codec, similarity, locale and the like), and seeds DEADBEEF and 5EED1234 fail the same way.

The parent-field half shares the root cause and was executed in the hunt's longer tests (listed above); it is not part of this repro.

Regression test: this same test; it passes with the recommended fix. Run ./gradlew tidy before committing it: google-java-format wraps its line 36, which is over 100 characters.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Also fails on the 10.6.0 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
cp $REPRO/TestMultiPassIndexSplitterSoftDeletes.java $LUCENE/lucene/misc/src/test/org/apache/lucene/misc/index/
cd $LUCENE && ./gradlew :lucene:misc:test --tests TestMultiPassIndexSplitterSoftDeletes -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true
TestMultiPassIndexSplitterSoftDeletes > testSplitIndexWithSoftDeletes FAILED
    java.lang.AssertionError: expected the 2 live docs split into 2 parts, got: java.lang.IllegalArgumentException: this index has [soft] as soft-deletes already but soft-deletes field is not configured in IWC
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:6CC305A8ED4B11FF]:0)
        at org.apache.lucene.misc.index.TestMultiPassIndexSplitterSoftDeletes.testSplitIndexWithSoftDeletes(TestMultiPassIndexSplitterSoftDeletes.java:49)

        Caused by:
        java.lang.IllegalArgumentException: this index has [soft] as soft-deletes already but soft-deletes field is not configured in IWC
            at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.index.FieldInfos$FieldNumbers.verifySoftDeletedFieldName(FieldInfos.java:462)
            at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.index.FieldInfos$FieldNumbers.verifyFieldInfo(FieldInfos.java:403)
            at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.index.IndexWriter.addIndexes(IndexWriter.java:3292)
            at org.apache.lucene.misc.index.MultiPassIndexSplitter.split(MultiPassIndexSplitter.java:113)
            at org.apache.lucene.misc.index.TestMultiPassIndexSplitterSoftDeletes.testSplitIndexWithSoftDeletes(TestMultiPassIndexSplitterSoftDeletes.java:47)

1 test completed, 1 failed

# -Ptests.asserts=false: the same failure
# with the fix applied: BUILD SUCCESSFUL (assertions on and off)

Why it looks unintended

  • split() accepts a source that "can have deletions", and a soft-deleted document "is treated as if it has been hard-deleted". Its only documented limit is breaking up document blocks.
  • Core tests copy such an index into a writer configured with the same field; the splitter cannot.
  • The split worked just before the 2018 check (LUCENE-8335) and broke with it.
The full argument221 words
  • Contract. "This tool splits input index into multiple equal parts" (MultiPassIndexSplitter.java:43); @param in "source index, can have deletions, can have multiple segments (or multiple readers)" (:63); the only stated limitation is that it "can easily break up" document blocks (:53-55). setSoftDeletesField: a soft-deleted document "is treated as if it has been hard-deleted through the IndexWriter API" (IndexWriterConfig.java:541-545).
  • Tests. TestMultiPassIndexSplitter builds plain indexes only. No test splits a soft-deletes or parent-field index or asserts the rejection for this tool. The core tests that assert the two messages are about a writer whose config disagrees with its index (TestIndexWriter.testPreventChangingSoftDeletesField, testIndexWithParentFieldIsCongruent), and TestAddIndexes.testAddIndicesWithSoftDeletes and testIllegalParentDocChange copy such an index into a destination configured with the same field.
  • History. The splitter's fixed config is older than both checks (LUCENE-2294, 2010; LUCENE-5871, 2014). The checks came from core changes that did not touch lucene/misc and mention no tool: LUCENE-8335 (#9382, 2018) and #12829 (2024). At the branch_7x parent of LUCENE-8335, which already had soft deletes, the split worked; at that commit it throws (executed).
  • Normal indexes. Lucene's own RandomIndexWriter turns soft deletes on at random in ordinary tests, and PKIndexSplitter and IndexUpgrader accept a config (IndexRearranger's constructors take one too, but since 10.0 it keeps only the analyzer, a separate row, LFV-695).
The devil's-advocate check158 words

The strongest case: an old command-line tool that never took a config; in LUCENE-3296 (#4369, 2011, an enhancement) committers gave PKIndexSplitter config constructors and left this class without one; soft deletes are an expert feature; and for the parent field the class already warns that it breaks blocks, so rejecting such an index may even be the safer outcome.

Why it fails for soft deletes: the 2011 decision was about defaults, seven years before soft deletes existed; no javadoc, test, CHANGES or MIGRATE entry excludes such indexes; "can have deletions" covers them; and core shows the supported copy path. For the parent field the argument is partly right (see the fix's trade-off), which is why that half is secondary. Three reviews rated the works-as-designed case moderate and found it does not hold for the soft-deletes half (verifier and the refute-lens review, both OpenAI GPT-5.6, and the devil's-advocate verifier): a classification risk (bug or improvement), not evidence of intent.

Suggested fix

Recommended Give each part the input's soft-deletes and parent field names (validated)

Read the input's soft-deletes and parent field names once with FieldInfos.getMergedFieldInfos(in) and set them on each part's config (+10/-1). The repro, the hunt's longer tests and the misc module pass. Caveat: a split that cuts a document block then succeeds but breaks it under a declared parent field, so for that half it is a policy choice.

// before the loop:
// each part must name the input's soft-deletes and parent fields (null if none), or
// IndexWriter#addIndexes rejects the input's field infos
FieldInfos inputFieldInfos = FieldInfos.getMergedFieldInfos(in);
// in the loop, instead of new IndexWriterConfig(null).setOpenMode(OpenMode.CREATE):
IndexWriter w =
    new IndexWriter(
        outputs[i],
        new IndexWriterConfig(null)
            .setOpenMode(OpenMode.CREATE)
            .setSoftDeletesField(inputFieldInfos.getSoftDeletesField())
            .setParentField(inputFieldInfos.getParentField()));
All options and trade-offs3 options · 614 words

Option A was built and run; B changes the API; C is not recommended.

Give each part the input's soft-deletes and parent field names (validated) (recommended)

In split() (MultiPassIndexSplitter.java:83-109), read the input's merged field infos once before the loop and set both names on each part's config (null, the default, when the input has none). The hunt's fix sketch did the same inside the loop with fully qualified names.

// before the loop:
// each part must name the input's soft-deletes and parent fields (null if none), or
// IndexWriter#addIndexes rejects the input's field infos
FieldInfos inputFieldInfos = FieldInfos.getMergedFieldInfos(in);
// in the loop, instead of new IndexWriterConfig(null).setOpenMode(OpenMode.CREATE):
IndexWriter w =
    new IndexWriter(
        outputs[i],
        new IndexWriterConfig(null)
            .setOpenMode(OpenMode.CREATE)
            .setSoftDeletesField(inputFieldInfos.getSoftDeletesField())
            .setParentField(inputFieldInfos.getParentField()));

Trade-offs

  • Parent field. With the fix a split that cuts a block succeeds but leaves a part whose segment ends in child documents with no parent after them, under a declared parent field. A round-robin split always cuts blocks (executed by the intended-behaviour verifier); a sequential split does when a part boundary falls inside a block (executed by the fact check on an index-sorted block index). That is the documented "can easily break up such document groups" behaviour, now in an index that declares blocks, and the precondition of a separate registry row (LFV-417: BP reordering merges then throw). So for the parent field this option is a policy choice for the maintainers, not a finished fix: keep rejecting parent-field input with a clearer message, or make the split block-aware.
  • Soft deletes. The markers are copied with the documents into parts configured with the same field, so soft-deleted documents stay deleted (an independent check read the input with a plain reader, as main() does). Never strip the mark instead: that would bring soft-deleted documents back.
  • Mixed inputs. One config cannot name two fields: getMergedFieldInfos throws if the input's leaves (for example a MultiReader over two indexes) disagree on the parent field or use different soft-deletes field names (read from the code). Such an input is rejected today anyway.
  • Other settings. Only the two field names are carried over; other settings of the input, such as an index sort, are still not set on the parts (the config-supplier option would let the caller choose them).

What the checks validated

Applied to a scratch worktree of 645d4fe14a20 and run through gradle with OpenJDK 25.0.3, seed DEADBEEF (2026-10-30):

  • stock: the minimal test fails with assertions on and off;
  • with the fix: the minimal test passes with assertions on and off; the hunt's and an independent verifier's longer tests (soft deletes and parent field) pass 5 of 5; TestMultiPassIndexSplitter (2 tests) and the whole :lucene:misc:test pass (24 classes, 317 tests, 0 failures, 9 skipped); ecjLint and forbiddenApis for misc pass, and google-java-format accepts the fix but wraps line 36 of the minimal test (run ./gradlew tidy before committing it); the diff was reverted and the worktree removed afterwards.
  • With the same change shadowed into the stock jars, a probe splits the minimal input into a and b (1 live document per part, soft configured on both), for the wrapped and the plain-reader input.

API change: let the caller supply the parts' configs

A split(..., Supplier<IndexWriterConfig>) overload or constructor, as PKIndexSplitter has (LUCENE-3296).

Trade-offs

Fully explicit, and it also lets a caller choose codec or index sort and covers the previous-major-version rejection noted in LFV-554. But callers must know to set the fields, the command line still needs option A or new options, and it adds public API. Not run.

Document that such indexes cannot be split

Javadoc change only.

Trade-offs

Leaves the tool unusable for soft-deletes and parent-field indexes, while the core already supports copying them.

Downstream note

No use of MultiPassIndexSplitter was found in Solr, Elasticsearch or OpenSearch (code search of their default branches).

How it was found

A randomized round-trip test built random indexes and ran them through Lucene's index tools. Of 597 sources with a soft-deletes or parent field, MultiPassIndexSplitter threw on 525 while PKIndexSplitter with matching configs reproduced all 597. The finder then cut it down to a public-API JUnit.

The method in full151 words

Method: randomized metamorphic round-trip differential testing of the index tools (the hunt classes it as non-formal).

  • A harness built random source indexes, some with a soft-deletes field, a parent field or both, split or merged them with each tool, and compared the parts' live documents with the source's.
  • In one batch of 10 JVMs (5 with assertions on, 5 off), MultiPassIndexSplitter threw IllegalArgumentException on 525 of 597 such sources (412 parent-field and 113 soft-deletes messages; the other 72 split), while PKIndexSplitter with matching configs reproduced all 597 exactly.
  • The finder minimised it to a two-test public-API JUnit (soft deletes, parent field) on the stock jars; the minimal repro above is its soft-deletes test cut down to two documents and one soft update.
  • The same harness saw IndexMergeTool, which has the same bare config, reject such sources too; no verifier traced or ran that, so it is only a sibling here.

Prior reports, history and checks

Prior reports

None found: no issue, PR, commit, list thread, JIRA entry or downstream report describes this failure or asks for a way to pass these settings (230 query records, 18 hits read, 2026-10-26; refreshed 2026-10-30). Closest: LUCENE-3296 (#4369), which gave PKIndexSplitter config constructors in 2011.

Searches4 searches
  • #4369 (LUCENE-3296, 2011): config constructors for PKIndexSplitter; the same commit gave MultiPassIndexSplitter only a Version argument, since removed. The precedent for option B and the source of the "enhancement" framing; it predates both features and reports no failure.
  • #9382 (LUCENE-8335) and #12829: added the two checks; neither touches lucene/misc or mentions a tool. Exact-phrase searches for both messages find only these.
  • #3034, #4700: introduced and rewrote the splitter. #10246: IndexMergeTool options, no soft-deletes option. #7044: misc tools and write.lock, another defect.
  • Same pattern elsewhere (separate rows): IndexMergeTool builds its writer with the same bare config; LFV-615 (IndexUpgrader, index sort); LFV-695 (IndexRearranger, since 10.0).
  • Not fixed on upstream main 2f424fd428da (2026-10-30); open PRs #13951, #14996 and #16847 touch the file, but none changes the parts' config settings (#13951 only adds an (Analyzer) cast on that line).
  • Limits: keyword search; GitHub search reads at most 100 results per query; private reports cannot be searched.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub apache/lucene issues, PRs and commits2026-10-26116 issue and PR queries (tool names with soft deletes, parent field, config; both error messages as exact phrases); commit searches and git log -S on both messages; file lists of all 407 open PRsrelated items only (#4369, #9382, #12829, #3034, #4700, #10246, #7044); none the same bug
Downstream (Solr, Elasticsearch, OpenSearch), mailing lists, JIRA archive, web2026-10-26downstream code searches; 67 list queries over dev@, java-user@, issues@, commits@ lucene and the Solr lists; 15 JIRA-archive queries; 8 web queriesno report; every list thread naming the class dates from 2009-2017, before the 2018 check
Intent search by a verifier2026-10-2644 more queries aimed at by-design or won't-fix statements (16 GitHub, 3 commit, 2 downstream, 20 list, 3 JIRA); 5 more web queries were generated but not runnone
GitHub refresh2026-10-30issue and PR search for MultiPassIndexSplitter sorted by creation; the soft-deletes and parent-field messages; upstream main file history (GitHub API)nothing newer than 2012; the soft-deletes message finds only #9382; no commit to the file after #16255 on upstream main 2f424fd428da

History

Two halves: the soft-deletes half since LUCENE-8335 (2018-06-04, first release 7.4.0, which also first shipped soft deletes), the parent-field half since #12829 (2024-01-11, first release 9.10.0). The tool's fixed config is older. Fails on every release run from 7.4.0 to 10.6.0 and on main; a commit-level A/B pins the soft-deletes half.

Versions tested6 rows

Soft-deletes half: LUCENE-8335 "Enforce soft-deletes field up-front" (#9382), e7a0a12926c on master and 67b6593e7ad on branch_7x, 2018-06-04 (a first landing two days earlier was reverted the same day); committed by s1monw from a patch by dnhatn. Parent-field half: #12829 "Add support for index sorting with document blocks" (by s1monw), df6bd25ce44 on main (2024-01-11) and 0aa88910ca9 on branch_9x (authored 2024-01-11, committed 2024-01-17); it added setParentField and the check together.

The splitter has built its parts with a fixed config since LUCENE-2294 (2010), in its current form new IndexWriterConfig(null).setOpenMode(CREATE) since LUCENE-5871 (2014); neither core change touched lucene/misc. Up to 9.3.x the rejection came from inside the addIndexes merge; since LUCENE-10216 (9.4.0) it runs up front, with the same message. Every failing history run had a passing plain-index control on the same build (in the same JVM for the release runs; the commit-level runs ran it as a separate launch); the releases in between share the code path.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails, assertions on and off (gradle and standalone); passes with the fix
10.6.0 affectedminimal test fails on the release jars, assertions on and off; the hunt's two tests fail 2 of 2
9.10.0, 9.12.3, 10.0.0, 10.5.2 affectedboth halves fail (the hunt's and the verifiers' tests)
7.4.0, 7.7.3, 8.0.0, 8.11.4, 9.9.2 soft deletesthe soft-deletes test fails (7.4.0 with assertions on and off); the parent-field API does not exist yet
branch_7x 67b6593e7ad (LUCENE-8335) vs parent 6f6b7f57edd bisectthe commit rejects the soft-deletes index; its parent splits it correctly (both modes); controls pass on both
7.3.1 and older no such APIno soft deletes (compile error)

Independent checks

Reproduced on stock main by an independent reproducer (its own tests, a plain control passing), a devil's-advocate verifier, an adjudication and an audit with new seeds; cross-model reviews (OpenAI GPT-5.6) could not run Java, so separate runs re-ran the finder's test for them. The fix passed every time.

Checks and verdicts353 words

Each check ran its own tests or re-ran an earlier one on stock main; the plain-index controls pass.

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidate525 of 597 random soft-deletes or parent-field sources rejected; two-test JUnit fails 2 of 2 on main (assertions on and off), 10.5.2 and 9.12.3; the fix passes, and so does upstream TestMultiPassIndexSplitter.
Cross-model review (OpenAI GPT-5.6, refute lens, read-only) not real (low, procedural)Could not run Java and it votes not real without an execution of its own; agreed on the root cause and called the static evidence strongly supportive; a separate run re-ran the finder's test: fail 2 of 2.
Independent reproducer realOwn 3 tests: 2 fail on main (assertions on and off), 10.5.2 and 9.12.3, the plain control passes; with the fix 3 of 3 pass and soft-deleted copies stay deleted.
Works-as-designed lens (OpenAI GPT-5.6, read-only) real (medium)Rated the intended case moderate and rejected it; the finder's test re-run with assertions on and off: fail 2 of 2.
Intended-behaviour devil's advocate real (moderate)Probe: 3 of 6 fail on main and 10.5.2 (plain reader, sorted block index, round-robin block split), 3 controls pass; showed the parent-field half is weak (orphan children with the fix).
Adjudication confirmed (medium), class minorRe-ran the independent reproduction's tests: 2 of 3 fail with assertions on and off, the control passes.
Registration check and audit keepFresh seeds fail on main; root cause confirmed by code reading.
Minimal repro and this doc's runs still fails (2026-10-30)One-method test fails on main with assertions on and off (standalone and gradle) and on 10.6.0; with the fix it passes and the misc module passes.
Independent fact checks of this doc (one of them OpenAI GPT-5.6, read-only) real, as described (2026-10-30)Re-ran the gradle repro (fails with assertions on and off; passes with the fix, misc module 317 tests pass), the standalone repro on main, 10.6.0 and 9.12.3, and an own probe of every row of the outcome table. The wording errors they found are fixed.

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

Finding record

LFV-618, snapshot of 2026-11-06

Registry title
MultiPassIndexSplitter builds every output part with a bare IndexWriterConfig and accepts no config, so it cannot split any index that uses soft deletes (7.4.0+) or has a parent field (9.10.0+): split() and its command line throw IllegalArgumentException before any part gets a document
Area
misc / index tools (MultiPassIndexSplitter.split, output IndexWriterConfig); IndexWriter.addIndexes field-role checks
Priority
Low, rank 668: A little-used misc tool fails loudly every time on soft-deletes or parent-field indexes without damaging anything.
Impact class
minor
Impact note
Only users of the lucene/misc MultiPassIndexSplitter (its command line, or split(IndexReader, Directory[], boolean)) are hit, and only on an index that uses soft deletes, meaning at least one soft update or soft delete was written under IndexWriterConfig#setSoftDeletesField (7.4.0 and later; a configured but never written field does not trigger it), or that has a parent field set with IndexWriterConfig#setParentField (9.10.0 and later; from 10.0 every index that combines index sorting with document blocks needs one). Every split of such an index then fails at once with IllegalArgumentException, in both split modes, with -ea and -da, and with no scale or timing condition; the tool's API offers no way to pass a config, so the only workarounds are PKIndexSplitter with its config constructors or addIndexes into writers configured with the same fields. Nothing is written or damaged: the source index stays byte-identical, no part receives a document, and the only side effect is the first part's writer left open with its write lock (LFV-554).
Summary
You split an index with MultiPassIndexSplitter, for example one written with IndexWriterConfig#setSoftDeletesField("soft") and a single softUpdateDocument call, or one written with IndexWriterConfig#setParentField("_parent"), and expect it to split like any other index: the split() javadoc says the source 'can have deletions, can have multiple segments (or multiple readers)', and the only documented limitation is that document blocks may be broken up. Instead split() opens every part's IndexWriter with new IndexWriterConfig(null), which names no soft-deletes or parent field and cannot be replaced by the caller, so IndexWriter.addIndexes rejects the input's field infos with IllegalArgumentException ('this index has [soft] as soft-deletes already but soft-deletes field is not configured in IWC', or 'can't add field [_parent] as parent document field; this IndexWriter has no parent document field configured') and nothing is split.
Introduced
Two halves, each introduced by a core change that began checking a field role against the writer's config; neither touched lucene/misc, and the splitter's fixed output config was not adapted. (1) Soft deletes: LUCENE-8335 'Enforce soft-deletes field up-front' (apache/lucene#9382, 'Do not allow changing soft-deletes field'), e7a0a12926c399758a4021715a7419e22e59dab6 on master and 67b6593e7adbce76532e285cef42118e6cc3448f on branch_7x, both 2018-06-04 (a first landing a2d92766741 on 2018-06-02 was reverted the same day by 3dc4fa199c1) [exec at commit level: the branch_7x parent 6f6b7f57edd splits a soft-deletes index, 67b6593e7ad rejects it; plain-index controls pass on both]. (2) Parent field: apache/lucene#12829 'Add support for index sorting with document blocks', df6bd25ce44ead0b38cffca6ef9bc3a9ce63dbea on main and 0aa88910ca9a1032d288996d14203eac4953f2de on branch_9x, both 2024-01-11; it added IndexWriterConfig#setParentField and the rejection together [read; bounded by execution: 9.9.2 lacks the API, 9.10.0 rejects]. The splitter's own config is older and unchanged: a default IndexWriterConfig since LUCENE-2294 (2010) and 'new IndexWriterConfig(null).setOpenMode(CREATE)' since LUCENE-5871 (2014), only reformatted since [read: git blame and git log -S on MultiPassIndexSplitter.java:108-109] (2018-06-04 (soft-deletes half, LUCENE-8335); 2024-01-11 (parent-field half, #12829))
First affected version
7.4.0 (tagged 2018-06-22) for soft deletes [exec, -ea and -da]: it is the first release with soft deletes at all, so no released version ever split a soft-deletes index (the two-month window in which the split worked, executed at 6f6b7f57edd, was never released); 9.10.0 (tagged 2024-02-20) for the parent field [exec], the first release with IndexWriterConfig#setParentField. 7.3.1 and 9.9.2 lack the respective APIs (compile errors) [exec]
Affected versions
Soft-deletes half: every release from 7.4.0 to 10.5.2, plus main a9779ef3c193; parent-field half: 9.10.0 to 10.5.2, plus main; never fixed (MultiPassIndexSplitter.java last changed upstream on 2026-06-14 by #16255, a build-check change; upstream main a95c754ee003 checked read-only on 2026-10-26). [exec] by the follow-up's history runs, each with a passing plain-index control in the same JVM: 7.4.0 (-ea and -da), 7.7.3, 8.0.0, 8.11.4 and 9.9.2 (soft-deletes half), 9.10.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da; both halves); the independent reproduction's own JUnit also fails on 10.5.2 and 9.12.3, and the intended-behaviour check's probe on 10.5.2. [read] the releases in between share the code path (release membership from git tag --contains; 8.9.0-8.11.4 are not tagged in the history clone; 8.11.4 itself was executed). The rejection site moved without a change in behaviour: up to 9.3.x inside the addIndexes merge (FieldInfos$FieldNumbers.addOrGet), since LUCENE-10216 (9.4.0) up front in addIndexes (FieldNumbers.verifyFieldInfo); same message [exec: stack frames]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record, verdict new): 230 query records (scripted prior-report searches by the finder and by runs A, B and C, plus own searches: 120 GitHub apache/lucene issue and PR queries, 12 commit queries, 6 downstream queries over Solr, Elasticsearch and OpenSearch, 67 mailing-list queries over dev@, java-user@, issues@ and commits@ lucene and the Solr lists, 15 JIRA-archive queries, 8 web queries, the upstream path history and git log -S on both messages), 18 hits read and classified, and a file-list scan of all 407 open apache/lucene PRs. Related, not this bug: LUCENE-3296 / #4369 (2011, config constructors for the sibling PKIndexSplitter; in the same commit MultiPassIndexSplitter got only a Version argument, since removed), LUCENE-8335 / #9382 and #12829 (added the two checks; neither touches lucene/misc or mentions a tool), LUCENE-1959 / #3034 and LUCENE-3626 / #4700 (introduced and rewrote the splitter), LUCENE-9206 / #10246 (IndexMergeTool options, no soft-deletes or parent option), LUCENE-5982 / #7044 (misc tools should lock the source; other defect); the open PRs #13951, #14996 and #16847 touch the file without changing the config. Not fixed upstream (MultiPassIndexSplitter.java unchanged since 2026-06-14 on upstream main a95c754ee003).
Lucene commit tested
main 645d4fe14a20 (2026-10-28; upstream main 2f424fd428da, 2026-10-30, has no later commit to MultiPassIndexSplitter.java than #16255 of 2026-06-14): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner; with the fix applied it passes, and so does the misc module. It also fails on the 10.6.0 release jars (assertions on and off). The hunt's earlier runs (the hunt on a9779ef3c193, same files): the hunt's history tests (adapted to each version's API) fail on 7.4.0, 7.7.3, 8.0.0, 8.11.4, 9.9.2, 9.10.0, 9.12.3, 10.0.0 and 10.5.2, and a commit-level A/B pins the soft-deletes half to LUCENE-8335.

Lucene and upstream sources

Finding record
  • lucene/misc/src/java/org/apache/lucene/misc/index/MultiPassIndexSplitter.java:43 (at a9779ef3c193) (class javadoc: 'This tool splits input index into multiple equal parts'), :53-55 (only stated limitation: it can break up document blocks), :63 ('@param in source index, can have deletions, can have multiple segments (or multiple readers)'), :70 (split(IndexReader, Directory[], boolean), the only API), :108-109 (new IndexWriter(outputs[i], new IndexWriterConfig(null).setOpenMode(OpenMode.CREATE))), :113 (w.addIndexes), :157 and :182 (main() opens a plain DirectoryReader and calls split)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3288-3295 (at a9779ef3c193) (addIndexes(CodecReader...) up-front verifyFieldInfo over every leaf) and :1513-1516 (offline splitting tools 'will likely break' blocks, 'Use such tools at your own risk!'); lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:401-404 (verifyFieldInfo), :459-465 (soft-deletes message), :484-490 (parent-field message)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:541-549 (at a9779ef3c193) (a soft-deleted document 'is treated as if it has been hard-deleted') and :579-590 (setParentField); lucene/MIGRATE.md:875-881 (#12829: parent field required for index sorting with document blocks from 10.0); lucene/core/src/test/org/apache/lucene/index/TestAddIndexes.java:1767 (testAddIndicesWithSoftDeletes) and :1945 (testIllegalParentDocChange): addIndexes succeeds with a matching destination config; lucene/misc/src/java/org/apache/lucene/misc/index/PKIndexSplitter.java:58-59, :67-68, :91-92 (convenience constructors with a bare config, plus IndexWriterConfig constructors)
  • a Lucene main checkout: git diff ea57360912ed a9779ef3c193 is empty for MultiPassIndexSplitter.java, IndexWriter.java, FieldInfos.java and IndexMergeTool.java (so the reviews that read ea57360912ed and the finder's batch run on it apply unchanged)
The finding in 30 seconds
  • lucene/misc/src/java/org/apache/lucene/misc/index/MultiPassIndexSplitter.java:43, (at 645d4fe14a20) :53-55, :63, :70, :81, :84-107, :108-109, :113, :120-183 (:157, :182), :186
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1267-1269, (at 645d4fe14a20) :3275, :3288-3295 (:3292); FieldInfos.java:401-408, :459-465, :484-490; IndexWriterConfig.java:541-545, :579-590
Who is affected
  • lucene/MIGRATE.md:875-881 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/index/TestAddIndexes.java:1767, (at 645d4fe14a20) :1945
Why it looks unintended
  • lucene/misc/src/java/org/apache/lucene/misc/index/MultiPassIndexSplitter.java:43, (at 645d4fe14a20) :53-55, :63
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:541-545 (at 645d4fe14a20)
  • lucene/misc/src/test/org/apache/lucene/misc/index/TestMultiPassIndexSplitter.java:39-46; (at 645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestAddIndexes.java:1767, :1945; TestIndexWriter.java:3792, :4873
  • lucene/test-framework/src/java/org/apache/lucene/tests/index/RandomIndexWriter.java:141-161 (at 645d4fe14a20)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:196-215, (at 645d4fe14a20) :302-309; lucene/misc/src/java/org/apache/lucene/misc/index/PKIndexSplitter.java:62-68, :86-92
  • lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:196-238 (at 645d4fe14a20) (getMergedFieldInfos: one soft-deletes name picked, the builder rejects another at :459-482; getAndValidateParentField throws IllegalStateException)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro, the fix diff and the executed probe of the split parts.

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.