All findings · Lucene finding · LFV-1050 · index-sort-flush-rejected-trailing-docs-without-parent

With an index sort and a parent field, a rejected document left as the last document of a segment makes the next commit throw and close IndexWriter, losing every uncommitted document

With an index sort and a parent field, a rejected document that is the last one added before a commit, after a document block, makes commit() throw ArrayIndexOutOfBoundsException: Index 2147483647: the writer closes itself and every uncommitted document is lost. Every release from 9.10.0 to 10.6.0 has it.

Summary

Priority
Medium, rank 161An opt-in setup of nested docs under an index sort where an ordinary rejected document closes IndexWriter and loses uncommitted documents, released since 9.10; it is loud and recoverable.
Impact
crash
Since
9.10.0 (#12829, 2024)introduced 2024-01-11 · about 2 years
Affected
every release from 9.10.0 to 10.6.0, and main
Status
documented
Found by
code reading while building an Alloy model of the flush-time sort with blocks

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

The flush-time sort compares every document by its next parent, parents.nextSetBit(docID). A document rejected by the schema check has used a doc ID but never gets a parent marker. When it is the segment's last doc ID there is no next parent: nextSetBit returns Integer.MAX_VALUE, and the sorter uses it as an array index.

Background: document blocks under an index sort

  1. A document block (addDocuments) is a group of child documents followed by their parent. Block-join queries need the block to stay together and in this order.
  2. With an index sort, each new segment is reordered when it is flushed. To keep blocks together, IndexWriterConfig#setParentField makes Lucene mark the last document of every addDocument / addDocuments call as a parent, and the flush sorts each document by its next parent's values.
  3. A document that IndexWriter rejects (a field-type conflict, an immense term) is not added, but its doc ID is already used: it stays in the in-memory segment as a deleted document.
Summary of the bug: with an index sort and a parent field, doc 0 (child) and doc 1 (parent, with the parent marker) form an accepted block; doc 2 is rejected with IllegalArgumentException and stays as a deleted document without a parent marker; at commit the flush-time sort asks nextSetBit(2) for doc 2's next parent and gets 2147483647 (NO_MORE_DOCS); values[2147483647] in an array of length 3 throws ArrayIndexOutOfBoundsException; IndexWriter closes itself and the 2 block documents are lost.
The repro in one picture: the accepted two-document block, the rejected document left without a parent marker, and the flush-time sort that asks for its next parent and gets 2147483647. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Configure an index sort, new Sort(new SortField("sort", SortField.Type.LONG)), and setParentField("parent"). Add one block of two documents with addDocuments: an empty child (doc 0) and a parent (doc 1) with NumericDocValuesField("f", 1). Lucene marks doc 1 as the parent.
  2. Add a third document whose f is a SortedDocValuesField. IndexWriter rejects it with IllegalArgumentException (doc-values type conflict; upstream's own test expects this exception for such a conflict) and stays open. The rejected document has still used doc ID 2, which is marked deleted and has no parent marker.
  3. You expect commit() to keep the 2 block documents: upstream's test testFlushAfterInconsistentSchema asserts that a rejected document followed by commit() works under an index sort (there with single documents, no parent field, and the index sort set in half of its runs).
  4. Instead the flush sorts each document by its next parent. Doc 2 has none, so nextSetBit(2) returns 2147483647 (NO_MORE_DOCS), and the sorter reads values[2147483647] in an array of length 3: commit() throws ArrayIndexOutOfBoundsException.
  5. A flush exception is fatal for the writer: IndexWriter closes itself, nothing is committed and both block documents are lost. One accepted document after the rejection avoids it.

Who is affected

Applications that index nested documents as blocks under an index sort; for indexes created by 10.0.0 or later the parent field is mandatory there. It takes one accepted block in the in-memory segment and a rejected document (a field-type conflict, an immense term in a child) as that segment's last operation before a commit, NRT reopen or close.

Mechanism, terms and numbers773 words

With an index sort and IndexWriterConfig#setParentField, the setup for nested documents (and mandatory for document blocks under an index sort on indexes created by 10.0.0 or later), the flush-time sort compares every document by its next parent: in.compare(parents.nextSetBit(docID1), parents.nextSetBit(docID2)) (IndexingChain.java:261-274 on main 645d4fe14a20). That assumes every doc ID has a parent marker at or after it. A document that IndexWriter rejects with a non-aborting IllegalArgumentException (the per-field schema rule is documented in MIGRATE.md, and upstream's own test expects the exception) has already used a doc ID, which is only marked deleted, and a schema conflict throws before the parent marker is written. When that doc ID is the last one in the in-memory segment and the segment also holds an accepted multi-document block, nextSetBit returns NO_MORE_DOCS (Integer.MAX_VALUE) and the sorter uses it as an array index. The next commit(), NRT reopen or close() throws ArrayIndexOutOfBoundsException: Index 2147483647 out of bounds for length 3; an exception during flush is aborting, so IndexWriter closes itself and every document added since the last commit is lost. The committed index stays intact and a new writer works (executed with one earlier commit). Every release from 9.10.0 to 10.6.0 and main are affected; giving the last rejected doc ID a parent marker fixes it.

The bug

All lines are on main 645d4fe14a20, under lucene/core/src/java/org/apache/lucene/.

  1. Doc IDs are used before validation. DocumentsWriterPerThread.updateDocuments calls indexingChain.processDocument(numDocsInRAM++, doc, isLastDoc) (index/DocumentsWriterPerThread.java:289-291); the comment above it says "Even on exception, the document is still added (but marked deleted)" (:283-288). A block of more than one document sets hasBlocks on the segment (:296-299). After a non-aborting exception the used doc IDs are only marked deleted (deleteLastDocs, :303-307).
  2. The parent marker is written in pass 2. IndexingChain.processDocument checks the schema in pass 1 (initAndValidateFields, index/IndexingChain.java:640-642), where a doc-values type, point dimension, vector or index-options conflict throws. Only then, in pass 2, does it write the parent marker, and only for the last document of the call (:644-652). A document rejected in pass 1 never gets one (a pass-2 rejection of the call's last document does, see below); non-last documents of a block never get one either.
  3. The flush-time sort compares parents. maybeSortSegment (index/IndexingChain.java:251-299): when the segment has blocks and a parent field, it loads the markers into a BitSet and wraps every sort comparator as in.compare(parents.nextSetBit(docID1), parents.nextSetBit(docID2)) (:261-274).
  4. NO_MORE_DOCS becomes a doc ID. BitSet.nextSetBit returns NO_MORE_DOCS when no bit is set at or after the index (util/BitSet.java:89-93; NO_MORE_DOCS = Integer.MAX_VALUE, search/DocIdSetIterator.java:111). In the repro the markers are {1}, so nextSetBit(2) is 2147483647, and the LONG sorter reads values[docID] from an array of length maxDoc = 3 (index/IndexSorter.java:308-322). All 13 sort types the hunt tested fail, but not all at the same place: BinarySortField fails inside FixedBitSet.get (an AssertionError with -ea, "Index 33554431 out of bounds for length 1" with -da), while the 7 other types of the fact check's probe throw "Index 2147483647 out of bounds for length 3" in their comparator; the writer closes itself in all of them.
  5. The failure is tragic. An exception from IndexingChain.flush is aborting (index/IndexingChain.java:301-305): DocumentsWriterPerThread.flush records it and fires a tragic event (index/DocumentsWriterPerThread.java:585-589, :594-604), so IndexWriter closes itself and rolls back to the last commit.

Exact trigger

A bounded-exhaustive check on the real jar compared an exact model, "the flush fails if and only if the in-memory segment holds an accepted multi-document block and its last doc ID has no parent marker", against every script of 1 to 5 operations over 8 operations (an accepted single document or block; a single document, a block child or a block's last document rejected in pass 1 or pass 2): 37,448 scripts, 7,520 predicted and 7,520 observed failures, 0 mismatches (-ea); with -da, the 4,680 scripts of length 1 to 4, 740 = 740. The "never fails" model gives 740 mismatches on the same jar, so the check is not vacuous.

In words: the last operation on that segment was a pass-1 rejection of a single document or of a block's last document (doc-values type, point or vector dimension or similarity, index options, the reserved parent field name), or any rejection of a non-last block child (also an immense term or an analyzer IOException). A pass-2 rejection of a single document or a block's last document does not trigger it, because the marker is written first in pass 2. The experimental IndexWriter#addBatch path fails the same way.

Exposure and likelihood335 words
  • Who is hit. Applications that index nested documents as blocks (addDocuments / updateDocuments) under an index sort. For indexes created by 10.0.0 or later the parent field is mandatory for that (DocumentsWriterPerThread.java:272-281; MIGRATE: "IndexWriter requires a parent document field in order to use index sorting with document blocks"), so every such application has the configuration. On 9.x indexes it applies only if the parent field is set. addDocuments and updateDocuments are tagged @lucene.experimental (IndexWriter.java:1521, :1603, :1616); in the repro only the accepted block uses addDocuments, the rejected document goes through the non-experimental addDocument.
  • What it takes. One accepted multi-document block in the in-memory segment (one per indexing thread), and a rejected document as the last operation on that segment before it is flushed. The rejections come from ordinary input: a field whose doc-values type or point dimensions conflict with an earlier document, an immense term in a nested child, an analyzer exception in a child. Any accepted document after the rejection on the same segment avoids it.
  • What breaks. Loudly: the next commit(), NRT reopen or close() (with the default commitOnClose) throws, IndexWriter closes itself and every document buffered since the last commit is lost (by code reading also those in other threads' in-memory segments; the single-thread loss is executed). The committed index stays intact and a new IndexWriter opens on it (executed: with one earlier commit, that document survives the crash and a new writer adds a document and commits).
  • Default vs opt-in. Index sorting and blocks are opt-in. Within that: default codec and directory, assertions on or off, one thread, three documents.
  • How likely. Plausible. Rejected documents followed by a flush under an index sort do occur: the related #15125 crash was hit downstream, in a failed upgrade (OpenSearch#19660) and in what elastic/elasticsearch#153950 calls "both production failures"; #15125's own PR body shows a stack trace from a downstream engine. No public report of this crash was found in the recorded searches.

Minimal repro

One JUnit method on the public API, in org.apache.lucene.index like TestIndexSorting: an index sort, a parent field, a two-document block, one rejected document, then commit(). Three documents, no sort values, single thread; it fails with every seed tried.

TestIndexSortRejectedLastDocument.java (lucene/core, package org.apache.lucene.index, 1 test)49 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.NumericDocValuesField;
import org.apache.lucene.document.SortedDocValuesField;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestIndexSortRejectedLastDocument extends LuceneTestCase {

  public void testCommitAfterRejectedDocument() throws Exception {
    try (Directory dir = newDirectory()) {
      IndexWriterConfig iwc = new IndexWriterConfig();
      iwc.setIndexSort(new Sort(new SortField("sort", SortField.Type.LONG)));
      iwc.setParentField("parent");
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        w.addDocuments(List.of(List.of(), List.of(new NumericDocValuesField("f", 1))));
        expectThrows(
            IllegalArgumentException.class,
            () -> w.addDocument(List.of(new SortedDocValuesField("f", new BytesRef("x")))));
        // expected: commit keeps the 2 block docs;
        // actual: ArrayIndexOutOfBoundsException 2147483647, the writer closes itself
        w.commit();
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        assertEquals(2, r.numDocs());
      }
    }
  }
}
Command
cp $REPRO/TestIndexSortRejectedLastDocument.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestIndexSortRejectedLastDocument -Ptests.seed=DEADBEEF
Expected
commit() succeeds and the index holds the 2 block documents.
Observed
commit() throws ArrayIndexOutOfBoundsException: Index 2147483647 out of bounds for length 3; the writer closes itself and nothing is committed.

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

More files, environment, full output360 words

TestIndexSortRejectedLastDocument (49 lines with the license header, one test method, public API only). expectThrows checks the rejection; the test fails at w.commit() (line 42). It fails with every seed tried (DEADBEEF, 1234ABCD; -ea and -da); the test framework still randomizes the directory and codec through newDirectory(). Compared with the hunt's copy, its one comment is reworded and wrapped onto two lines so that checkGoogleJavaFormat passes with the file in the tree; the code is identical. The block is added with addDocuments, which is tagged @lucene.experimental; the rejected document goes through the non-experimental addDocument. As a regression test the method can go into TestIndexSorting unchanged.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.6.0 and 9.10.0 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (nothing platform-specific)
Command (environment)
cp $REPRO/TestIndexSortRejectedLastDocument.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestIndexSortRejectedLastDocument -Ptests.seed=DEADBEEF

Observed output

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF
TestIndexSortRejectedLastDocument > testCommitAfterRejectedDocument FAILED
    java.lang.ArrayIndexOutOfBoundsException: Index 2147483647 out of bounds for length 3
        at org.apache.lucene.index.IndexSorter$LongSorter.lambda$getDocComparator$0(IndexSorter.java:322)
        at org.apache.lucene.index.IndexingChain.lambda$maybeSortSegment$1(IndexingChain.java:273)
        at org.apache.lucene.index.Sorter.lambda$sortAndLeaveUnpacked$0(Sorter.java:230)
        at org.apache.lucene.index.Sorter.sort(Sorter.java:136)
        at org.apache.lucene.index.Sorter.sortAndLeaveUnpacked(Sorter.java:238)
        at org.apache.lucene.index.IndexingChain.maybeSortSegment(IndexingChain.java:297)
        at org.apache.lucene.index.IndexingChain.flush(IndexingChain.java:305)
        at org.apache.lucene.index.DocumentsWriterPerThread.flush(DocumentsWriterPerThread.java:492)
        at org.apache.lucene.index.DocumentsWriter.doFlush(DocumentsWriter.java:541)
        at org.apache.lucene.index.DocumentsWriter.maybeFlush(DocumentsWriter.java:496)
        at org.apache.lucene.index.DocumentsWriter.flushAllThreads(DocumentsWriter.java:688)
        at org.apache.lucene.index.IndexWriter.prepareCommitInternal(IndexWriter.java:3809)
        at org.apache.lucene.index.IndexWriter.commitInternal(IndexWriter.java:4253)
        at org.apache.lucene.index.IndexWriter.commit(IndexWriter.java:4215)
        at org.apache.lucene.index.TestIndexSortRejectedLastDocument.testCommitAfterRejectedDocument(TestIndexSortRejectedLastDocument.java:42)

# standalone runner, unpatched jars
main 645d4fe14a20, -ea, seed DEADBEEF   tests_run=1 failures=1  ArrayIndexOutOfBoundsException: Index 2147483647 out of bounds for length 3
main 645d4fe14a20, -da, seed DEADBEEF   tests_run=1 failures=1  (same)
main 645d4fe14a20, -da, seed 1234ABCD   tests_run=1 failures=1  (same)
10.6.0 release jar, -da                 tests_run=1 failures=1  (same)

# writer state after the failure (the same steps as a plain main(), ByteBuffersDirectory; diagram probe, -ea and -da)
PROBE A commit(): threw java.lang.ArrayIndexOutOfBoundsException: Index 2147483647 out of bounds for length 3 | isOpen=false tragic=ArrayIndexOutOfBoundsException sameAsThrown=true
PROBE A after: indexExists=false
# run A2: one committed document first, then the same steps
PROBE A2 after: indexExists=true numDocs=1
PROBE A2 new writer: addDocument + commit OK, numDocs=2

# close() instead of commit(): a variant of the same shape (sort values, the conflicting field on the child, FSDirectory)
close -> java.lang.ArrayIndexOutOfBoundsException: Index 2147483647 out of bounds for length 3 ; writer open=false ; tragic=java.lang.ArrayIndexOutOfBoundsException: Index 2147483647 out of bounds for length 3
index has a commit point: false

Why it looks unintended

  • Each self-close IndexWriter documents is an environmental failure (an Error, a checkpoint disaster, disk full while flushing); the comment on this path says a rejection must not "abort and shutdown" it.
  • Upstream fixed a flush crash after a rejected document under an index sort as a bug (#15125, no parent field).
  • "Index 2147483647" is NO_MORE_DOCS used as an array index.
The full argument295 words
  • Contracts. When IndexWriter closes itself: the class javadoc, "if you hit an Error, or disaster strikes during a checkpoint then IndexWriter will close itself" (IndexWriter.java:162-165), and getTragicException, "closed as a side-effect of a tragic exception, e.g. disk full while flushing a new segment" (:6108-6112); every documented case is an environmental failure, none a document IndexWriter rejected itself. addDocument: "if an Exception is hit ... the index will be consistent, but this document may not have been added" (:1465-1466), and a term over MAX_TERM_LENGTH gives an IllegalArgumentException (:1480-1481). The schema rule a doc-values type conflict breaks is documented in lucene/MIGRATE.md:1557-1567 (LUCENE-9334, "Require consistency between data-structures on a per-field basis"), and upstream's test expects an IllegalArgumentException for it (TestIndexSorting.java:3537). The DocumentsWriterPerThread comment on this path: a rejected document "won't cause the DWPT to be stale nor the entire IW to abort and shutdown. In such a case we only mark these docs as deleted" (DocumentsWriterPerThread.java:392-398). setParentField: the marker "is required" to keep blocks in order under index sorting (IndexWriterConfig.java:579-589), with no @lucene.experimental tag.
  • Upstream precedent. #15125 ("Handle inconsistent schema on flush with index sorts", merged 2025-09-29, first released in 10.5.1) fixed a NullPointerException in the same maybeSortSegment after a rejected document. Its test testFlushAfterInconsistentSchema (TestIndexSorting.java:3497-3556) asserts that a rejected single document followed by commit() works with an index sort (set in half of its runs), without a parent field.
  • History. The parent-aware comparator and setParentField came together in #12829 (2024); its comments and reviews never discuss rejected documents.
  • Tests. No upstream test combines an index sort, a parent field, an accepted block and a trailing rejection; testBlockContainsParentField rejects blocks but never has an accepted block in the segment.
The devil's-advocate check181 words

The strongest case for "intended or misuse": the addDocument javadoc promises only that the index stays consistent, and the last commit is intact; addDocuments is @lucene.experimental; feeding schema-conflicting documents is an application error; and blocks that lose their parent are a known unsupported state (s1monw on #12829, about deleting only a parent).

Why it fails: every self-close IndexWriter documents is an environmental failure (an Error, a checkpoint disaster, disk full while flushing), never a document it rejected itself; Lucene's answer to such input is a non-aborting IllegalArgumentException (the schema rule in MIGRATE.md:1557-1567, the exception upstream's test expects at TestIndexSorting.java:3537, and the DocumentsWriterPerThread comment that such a rejection must not "abort and shutdown" the writer); the rejected document goes through the non-experimental addDocument; the parentless doc ID is created by Lucene's own rejection handling, the user deletes nothing; and upstream fixed #15125, a rejected document then a flush under an index sort, as a bug. The intended-lens reviewer from another model family (OpenAI GPT-5.6) rated the argument weak, and the devil's-advocate verifier refuted each part of it.

Suggested fix

Recommended Give the last rejected doc ID a parent marker (validated)

When IndexWriter handles a non-aborting rejection, give the last used doc ID a parent marker if it has none; the rejected documents are deleted anyway (+31 lines in DocumentsWriterPerThread and IndexingChain). With it the repro and the closest suites pass (180 tests). Fixing only the sort comparator moves the crash to the next merge.

All options and trade-offs3 options ยท 581 words

One fix was validated with gradle in a scratch worktree at 645d4fe14a20 (JDK 25, -Ptests.seed=DEADBEEF); the other options were not built here.

Give the last rejected doc ID a parent marker (validated) (recommended)

DocumentsWriterPerThread (both updateDocuments and updateBatch) calls a new IndexingChain.markLastRejectedDocAsParent after deleteLastDocs; IndexingChain remembers the last doc ID that has a marker and, if the last used doc ID has none, writes one (registering the parent field first if this segment has none yet). +31/-0 in two files.

// DocumentsWriterPerThread, non-aborting failure path (updateDocuments and updateBatch):
deleteLastDocs(numDocsInRAM - docsInRamBefore);
if (numDocsInRAM > docsInRamBefore) {
  indexingChain.markLastRejectedDocAsParent(numDocsInRAM - 1);
}

// IndexingChain (lastParentDocID is set wherever a parent marker is written):
void markLastRejectedDocAsParent(int lastDocID) throws IOException {
  if (parentPf == null || lastDocID <= lastParentDocID) {
    return;
  }
  if (parentPf.fieldInfo == null) {
    initializeFieldInfo(parentPf);
    parentPf.trySetValidatedFrozenFieldType();
  }
  ((NumericDocValuesWriter) parentPf.docValuesWriter)
      .addValue(lastDocID, parentField.numericValue().longValue());
  lastParentDocID = lastDocID;
}

Trade-offs

  • Restores the invariant every block-aware consumer relies on (every doc ID has a parent at or after it) at the one place that breaks it. The rejected documents are deleted anyway, so the extra marker changes nothing visible; a block cut short by a rejected child becomes a deleted block.
  • Cost: one int store per parent on the indexing path; the rest runs only after a rejection.
  • It also registers the parent field in a segment whose only documents were rejected, which makes the minimal repros of two related hunt findings pass (a BP-reordering merge and a merge that expects the parent field in every segment). It does not touch the sorters, so the related bug where a user deletes only a block's parent still needs its own fix.

What the checks validated

  • Gradle, scratch worktree at 645d4fe14a20 + the diff + the minimal test: :lucene:core:checkGoogleJavaFormat passes; :lucene:core:test with TestIndexSortRejectedLastDocument 1, TestIndexSorting 62, TestIndexWriterExceptions 40 (3 skipped), TestIndexWriterExceptions2 1, TestColumnBatchIndexing 37 and TestAddIndexes 39: 180 tests, 0 failures, BUILD SUCCESSFUL. Without the diff the minimal test fails. Not run: the whole core test suite, so treat the diff as a starting point for review, not a finished patch.
  • Standalone runner: the minimal test passes with the fix with -ea and -da; the bounded-exhaustive check gives 0 failures and 0 mismatches over the 4,680 scripts of length 1 to 4 (-da), including a merge, a second commit and CheckIndex; the twin finders' repros pass; the refute-lens reviewer's port of the fix passes its own test, including forceMerge(1).

Make the sorters tolerate a trailing run without a parent (not built)

Treat documents with no following parent as their own block, in the flush-time comparator, in the merge-time MultiSorter and in Sorter.sort(LeafReader) (Sorter.java:185-199, the same parents.nextSetBit wrapper, used by SortingCodecReader.wrap).

Trade-offs

The comparator alone is not enough: a twin finder's comparator-only version makes the JUnit pass, but its model-based test still reported 15 failures in 400 runs (9 to 13 runs failed: the harness counts failure events and prints only the first 10, from 8 runs), because the flushed segment then ends with deleted parentless documents and the next merge throws in MultiSorter, which makes the same parents.nextSetBit(docID) assumption (MultiSorter.java:166-168). Changing these sites would also cover the delete-only-the-parent case.

Write the parent marker before pass-1 validation (not built)

Move the marker write ahead of initAndValidateFields.

Trade-offs

Incomplete by code reading: it covers a rejected single document or block's last document, but not a rejected non-last child, where the block's last document is never processed.

Downstream note

Services that index nested documents under an index sort and accept documents with arbitrary field types are the most exposed: one conflicting document, sent at the wrong moment, closes their writer.

How it was found

Code reading of the flush-time sort with document blocks, while building an Alloy model of the flush-time and merge-time doc maps; the Alloy check gave a 3-document counterexample and a JUnit confirmed it. Two other hunts reached the same root independently: code reading, then a fault-injecting model-based test and a bounded-exhaustive script enumerator.

The method in full203 words

Method: code reading, with a small Alloy model. The hunt's index-sorting hunt (2026-10-25) noticed that an earlier study had judged this flush site unreachable ("a flushed segment always ends with a parent"), and that a rejected document breaks that premise. Its Alloy 6.2 model of the flush-time and merge-time doc maps with blocks produced a 3-document counterexample (an accepted block, then a failed block whose document has no parent marker), and a 4-test JUnit on main confirmed it (2 tests fail).

  • An indexing-chain schema hunt (2026-10-25) found the same root by code reading; its fault-injecting model-based test reported 22 failures in 100 random runs on the stock jar (at least 11 runs failed; the 10 failures it printed are all an ArrayIndexOutOfBoundsException at flush or commit).
  • An IndexWriter API fault hunt (2026-10-26) reached the same root by code reading and characterised it with a bounded-exhaustive script enumerator: 31,314 single-threaded runs, and the only failing class was this one (78 runs, all with an index sort and a parent field).
  • An adjudication unit reduced the trigger to one addDocument, wrote the minimal test, checked an exact trigger model against the real jar over 37,448 scripts, and extended the fix to the addBatch path.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-29, re-checked 2026-10-30) in GitHub issues and PRs including the JIRA archive, the mailing lists, three downstream projects or the web. Closest: #15125, a different crash in the same method after a rejected document, fixed in 10.5.1.

Searches4 searches

Related, not reports of this bug: #15125 and its backports #16395 / #16396 (same method and trigger class; a null-FieldInfo NullPointerException without a parent field, fixed by a null check; the repro fails on main, which has that fix), #12829 (introduced the comparator; no discussion of rejected documents), #15960 (moved the marker write; trigger unchanged), #13340 / #13341 (parent field lost in an empty index), #7111 (LUCENE-6049, 2014: a non-aborting rejection breaking doc-values writing, before the parent field existed), the search-time index-sort issues #16884 (multi-valued fields, not blocks) and, with document blocks, #16885-#16887, #16893 and #16947, and downstream OpenSearch#19660 and elastic/elasticsearch#153950 (the #15125 crash). No open PR touches the fix site. Limits: keyword search only; GitHub search does not index PR diffs (open PRs were checked by their changed files); private reports are not visible. So the result is "no public report found in these searches", not proof that none exists.

What was searched, when, and what turned up
WhereWhenHowFound
Search tool (GitHub issues/PRs, commits, downstream repositories, dev@, java-user@, issues@, commits@, JIRA archive, web)2026-10-29two runs, each 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list, 4 JIRA-archive and 6 web queries (maybeSortSegment, setParentField, "parent field" "index sort", nextSetBit, deleteLastDocs, "Index 2147483647" ...)no report; closest #15125
GitHub search API and changed-file lists (apache/lucene, Elasticsearch, OpenSearch, Solr)2026-10-2914 apache/lucene queries; every item created since 2026-10-27; the changed files of all 409 open PRs and 53 recently closed PRs; 6 downstream queriesfive open PRs touch the files; none changes the comparator, the marker write or the rejection path
Full git history2026-10-29git log -S 'parents.nextSetBit(docID1)'; commits on main, branch_10x and branch_10_6 for the files involvedonly the introducing commit and its backport
GitHub, re-check2026-10-30maybeSortSegment, setParentField, "parent field" items updated since 2026-10-29; items created since 2026-10-29; commits on main and branch_10_6 for the fix-site files (a scripted repeat hit the search API's rate limit; its commit-list calls agreed)only #15125 and unrelated items; no change to the fix-site files

History

Introduced by #12829 (df6bd25ce44, 2024-01-11), which added setParentField and the parent-aware flush comparator in one commit; first released in 9.10.0. Fails on every release from 9.10.0 to 10.6.0 and on main; 9.9.2 cannot express the trigger.

Versions tested6 rows

Introduced by #12829, "Add support for index sorting with document blocks" (s1monw): df6bd25ce44 on main, authored and merged 2024-01-11; backport 0aa88910ca9 on branch_9x (committed 2024-01-17). It added setParentField, the parent marker and the parent-aware comparator together, so the trigger cannot be written before it and no bisect is possible (git log -S 'parents.nextSetBit(docID1)' finds only these two commits). Later changes kept the behaviour: #15960 (10.5.0) moved the marker write into IndexingChain, #16048 changed maybeSortSegment's return type. Upstream main (716d6f216f5) and branch_10_6 have the same code (2026-10-30).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle; standalone -ea and -da, two seeds)
10.6.0 affectedthe minimal test fails (release jar, -da)
9.10.0 first releasethe minimal test fails (-da)
9.10.0 to 10.6.0 (20 releases) affectedthe hunt's earlier, larger version of the test (sort values, the conflicting field on the child) fails on each (-da too on 9.10.0, 9.12.3, 10.6.0)
9.9.2 cannot expressno setParentField: the test does not compile
the fix (scratch worktree at 645d4fe14a20) not affectedthe test and 180 tests of the closest suites pass

Independent checks

A finder, an independent reproducer, reviewers from another model family (OpenAI GPT-5.6), an intended-behaviour devil's advocate and an adjudication each checked it; all call it real and agree on the root cause, and an exact trigger model matches the real jar on 37,448 operation scripts.

Checks and verdicts281 words

Caveats from the adjudication and the devil's advocate: the trigger needs an accepted block in the same in-memory segment and the rejected document as its last operation, so the real-world rate is low to moderate.

Each independent check and its verdict
CheckVerdictWhat it found
finder (judge unit) candidateRe-ran the twin finders' repros (fail), reduced the trigger to the one-method test (fails -ea/-da), checked the exact trigger model over 37,448 scripts (0 mismatches; vacuity control 740) and wrote the fix.
cross-model review, refute lens real, highOpenAI GPT-5.6. Own public-API test fails -ea/-da with two seeds; its control (an accepted document after the rejection) passes; its port of the fix passes, including forceMerge(1).
Independent reproducer realOwn test with a point-dimension rejection fails on main (-ea, -da), 10.6.0, 10.5.2 and 9.12.3; controls: an accepted document after the rejection avoids it; after the failure the writer is closed and no commit exists.
Cross-model review, intended lens real, mediumOpenAI GPT-5.6, read-only tools, so it ran no code; the "works as designed" case is weak; root cause agrees.
Intended-behaviour devil's advocate realRefuted every intended, misuse and unreachable argument; found the immense-term trigger in a block child.
adjudication confirmed, highRe-ran the finder's test (-ea, -da) and the independent reproduction's (-da) on main: all fail. Not noise; not security-sensitive.
this doc gradle repro and fixRan the minimal test with gradle in a scratch worktree at 645d4fe14a20 (fails) and on the 10.6.0, 9.10.0 and 9.9.2 jars; validated the recommended fix with gradle in a scratch worktree; re-checked upstream on 2026-10-30.

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

Finding record

LFV-1050, snapshot of 2026-11-06

Registry title
With an index sort and a parent field, a document that IndexWriter rejects (a documented, non-aborting IllegalArgumentException) as the last document of an in-memory segment that holds a document block makes the next flush throw ArrayIndexOutOfBoundsException 'Index 2147483647' and close IndexWriter, losing every uncommitted document (since 9.10.0)
Area
index / index sorting with document blocks (IndexingChain flush-time sort, DocumentsWriterPerThread non-aborting rejection)
Priority
Medium, rank 161: An opt-in setup of nested docs under an index sort where an ordinary rejected document closes IndexWriter and loses uncommitted documents, released since 9.10; it is loud and recoverable.
Impact class
crash
Impact note
Hits applications that configure an index sort together with IndexWriterConfig#setParentField (opt-in; the javadoc requires the parent field for document blocks under an index sort, the nested-document setup) when one accepted addDocuments block is in the in-memory segment and the last operation on that segment before a commit(), NRT reopen or close() is a document IndexWriter rejects with its non-aborting IllegalArgumentException (a doc-values type or point dimension conflict, an immense term in a block child, ...): the flush throws, IndexWriter closes itself with a tragic exception and every document buffered since the last commit is lost; default codec and directory, -ea and -da, ordinary scale, every built-in sort type; the committed index stays intact and a new writer works; released 9.10.0-10.6.0.
Summary
You configure an index sort and a parent field, add a block (a child and its parent) with addDocuments, then add one document that IndexWriter rejects with IllegalArgumentException because a field's doc-values type conflicts with the block's, and call commit(). You expect the rejected document simply not to be added and the commit to keep the two block documents, as it does without a parent field (upstream test testFlushAfterInconsistentSchema). Instead commit() throws ArrayIndexOutOfBoundsException 'Index 2147483647 out of bounds for length 3' from the flush-time sort, IndexWriter closes itself and both block documents are lost.
Introduced
apache/lucene#12829 'Add support for index sorting with document blocks' (s1monw): df6bd25ce44ead0b38cffca6ef9bc3a9ce63dbea on main, backport 0aa88910ca9 on branch_9x [read: git log -S 'parents.nextSetBit(docID1)' over the full history finds only these two commits; git blame on the comparator lines points to df6bd25ce44]. It added IndexWriterConfig#setParentField and the parent-aware flush comparator in the same commit, so the trigger cannot be written before it and no bisect is possible [exec: 9.9.2 does not compile the repro, 9.10.0 fails] (2024-01-11 (author date and main merge; branch_9x backport committed 2024-01-17))
First affected version
9.10.0 [exec]; 9.9.2, the last release before it, has no IndexWriterConfig#setParentField, so the trigger cannot be written (the repro does not compile) [exec]
Affected versions
[exec] the minimal JUnit fails on every release from 9.10.0 to 10.6.0 (9.10.0, 9.11.0, 9.11.1, 9.12.0-9.12.3, 10.0.0, 10.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2 and 10.6.0 from the published jars, commit 4ae946e5a9b) and on main 645d4fe14a20 with -ea, and also with -da on 9.10.0, 9.12.3, 10.6.0 and main; the independent reproduction's independent test fails on main (-ea and -da), 10.6.0, 10.5.2 and 9.12.3 (-da). Released in 10.6.0 (the tag releases/lucene/10.6.0 exists: e29d290eeb05, peels to 4ae946e5a9b). 10.4.0 (before #15960 moved the parent field into IndexingChain) and 10.5.0 (after it) fail the same way. Not new in 10.6. [read] not fixed upstream: no commit after 645d4fe on main (716d6f216f5) or branch_10_6 (7e5aec4370e) touches the comparator, DocumentsWriterPerThread or IndexSorter.
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
non-formal: code reading; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-29, read-only; the prior-report record verdict new, confidence high): two scripted prior-report searches (each 16 GitHub issue/PR queries on apache/lucene, 3 commit, 2 downstream, 20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA-archive and 6 web queries), 14 manual GitHub search-API queries, every apache/lucene issue and PR created or updated since 2026-10-27, the changed-file lists of all 409 open PRs and of the 53 closed PRs updated since 2026-10-20, the commits on main, branch_10x and branch_10_6 for the files involved, git log -S in the full history, the downstream repositories (Elasticsearch, OpenSearch, Solr) and 8 web queries; refresh ('maybeSortSegment' still finds only #15125; no PR updated since a recent cutoff touches the fix-site files). Related, not this bug: #15125 (merged 2025-09-29, backports #16395/#16396, first released in 10.5.1: a NullPointerException in the same method after a schema-rejected document, for a field without a FieldInfo, fixed by a null check; no parent field; its test testFlushAfterInconsistentSchema asserts the contract this bug breaks); #12829 (the introducing PR; its comments and reviews never discuss rejected documents); #15960 (moved the parent field into IndexingChain in 10.5.0; trigger unchanged); #13340/#13341 (parent field lost in an empty index); #7111 (LUCENE-6049, 2014: the same family in doc-values writing, predates the parent field); search-time block issues #16884-#16887, #16893, #16947; downstream OpenSearch #19660 and elastic/elasticsearch#153950 show the #15125 NullPointerException, not this crash; elastic/elasticsearch#105738 and #107349, OpenSearch #17026 and #18536, apache/solr#4619 adopt or request index sorting with nested documents, with no crash report. Not fixed upstream (main 716d6f216f5); no open PR touches the fix site.
Lucene commit tested
main 645d4fe14a20: the one-method test through gradle (fails; with the fix it passes together with TestIndexSorting, TestIndexWriterExceptions, TestIndexWriterExceptions2, TestColumnBatchIndexing and TestAddIndexes, 180 tests, and checkGoogleJavaFormat) and through the standalone runner (-ea, -da, two seeds); the same test on the 10.6.0 and 9.10.0 release jars (fails) and 9.9.2 (does not compile: no setParentField); the hunt's earlier copy of the test with sort values on all 20 releases from 9.10.0 to 10.6.0; an independent reproducer's own test on 10.6.0, 10.5.2 and 9.12.3; a bounded-exhaustive check against an exact trigger model on main: 37,448 operation scripts on the stock jar with -ea, 4,680 with -da, and 4,680 with the fix (-da).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:261-274 (at 645d4fe14a20) (maybeSortSegment: with blocks and a parent field, every index-sort comparator is wrapped as in.compare(parents.nextSetBit(docID1), parents.nextSetBit(docID2)); nextSetBit returns NO_MORE_DOCS = Integer.MAX_VALUE for a doc ID with no parent at or after it)
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:640-652 (at 645d4fe14a20) (processDocument: initAndValidateFields, where a schema conflict throws, runs in pass 1 at :641; the parent value is written in pass 2 at :645-652, and only for the block's last document)
  • lucene/core/src/java/org/apache/lucene/index/IndexSorter.java:318-322 (at 645d4fe14a20) (LongSorter comparator reads values[docID1] and values[docID2])
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:283-307 (at 645d4fe14a20) (a document consumes its doc ID before it is processed, numDocsInRAM++ at :291; setHasBlocks at :298; on a non-aborting exception the consumed doc IDs are only marked deleted, deleteLastDocs at :306) and :392-398 (comment: such a rejection 'won't cause the DWPT to be stale nor the entire IW to abort and shutdown')
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-165 (at 645d4fe14a20) (IndexWriter closes itself only 'if you hit an Error, or disaster strikes during a checkpoint') and :1465-1466 (addDocument: after an exception 'the index will be consistent, but this document may not have been added')
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:579-589 (at 645d4fe14a20) (setParentField javadoc: the parent marker is required to keep document blocks in order under index sorting)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexSorting.java:3497 (at 645d4fe14a20) (testFlushAfterInconsistentSchema, added by #15125: a rejected document, then commit, with an index sort and no parent field)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:272-281 (no parent field + index sort + block on a 10.x index: IllegalArgumentException), :283-291 (doc ID used before processing; 'Even on exception, the document is still added (but marked deleted)'), :296-299 (setHasBlocks), :303-307 (deleteLastDocs on a non-aborting exception), :585-604 (flush catch, onAbortingException, maybeAbort, onTragicEvent)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:251-299 (maybeSortSegment; :261-274 parent-aware comparator wrapper), :301-305 (flush: 'caller ... handles aborting on any exception from this method'), :600-652 (processDocument: pass 1 validation at :640-642, parent marker in pass 2 at :644-652), :824-833 (processParentFieldForColumnBatch)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexSorter.java:307-322 (LongSorter.getDocComparator: values[maxDoc], values[docID1] vs values[docID2])
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/BitSet.java:89-93 (nextSetBit returns NO_MORE_DOCS); a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:111 (NO_MORE_DOCS = Integer.MAX_VALUE)
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:272-281
  • a Lucene main checkout (645d4fe14a20) lucene/MIGRATE.md:875-881
  • https://github.com/elastic/elasticsearch/pull/153950 (PR body: 'Tests reproducing both production failures'; the write path fails the commit with the #15125 NullPointerException in IndexingChain.maybeSortSegment); https://github.com/opensearch-project/OpenSearch/issues/19660 (a failed upgrade; the shard failure carries the #15125 NullPointerException at IndexingChain.maybeSortSegment); https://github.com/apache/lucene/pull/15125 (PR body: a stack trace from Elasticsearch 9.2.0); read 2026-10-30
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1521, :1603, :1616 (@lucene.experimental on addDocuments and updateDocuments)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-165 (class javadoc), :1465-1466, :1480-1481, :1521-1523 (addDocuments @lucene.experimental), :6108-6112 (getTragicException javadoc: 'e.g. disk full while flushing a new segment')
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:392-398 (non-aborting rejection comment)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:579-589 (setParentField javadoc)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestIndexSorting.java:3497-3556 (testFlushAfterInconsistentSchema, #15125; index sort set only when random().nextBoolean() at :3499-3502; expectThrows(IllegalArgumentException.class, ...) at :3537)
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:884 (#15125 under 10.5.1)
  • a Lucene main checkout (645d4fe14a20) lucene/MIGRATE.md:1557-1567 ('Require consistency between data-structures on a per-field basis', LUCENE-9334, commit d03662c48bf)
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/MultiSorter.java:163-170 (setComparableValues: parents.nextSetBit(docID))
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/Sorter.java:185-199 (sort(LeafReader): the same parents.nextSetBit comparator wrapper); SortingCodecReader.java:446 (wrap(reader, sort) calls new Sorter(sort).sort(reader))
Diagrams
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:261-274

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

Repro files

About the diagrams

A small diagram, made in parallel with this doc from the executed runs.

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.