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.
- 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. - With an index sort, each new segment is reordered when it is flushed. To keep blocks together,
IndexWriterConfig#setParentFieldmakes Lucene mark the last document of everyaddDocument/addDocumentscall as a parent, and the flush sorts each document by its next parent's values. - A document that
IndexWriterrejects (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.
- Configure an index sort,
new Sort(new SortField("sort", SortField.Type.LONG)), andsetParentField("parent"). Add one block of two documents withaddDocuments: an empty child (doc 0) and a parent (doc 1) withNumericDocValuesField("f", 1). Lucene marks doc 1 as the parent. - Add a third document whose
fis aSortedDocValuesField.IndexWriterrejects it withIllegalArgumentException(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. - You expect
commit()to keep the 2 block documents: upstream's testtestFlushAfterInconsistentSchemaasserts that a rejected document followed bycommit()works under an index sort (there with single documents, no parent field, and the index sort set in half of its runs). - 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 readsvalues[2147483647]in an array of length 3:commit()throwsArrayIndexOutOfBoundsException. - A flush exception is fatal for the writer:
IndexWritercloses itself, nothing is committed and both block documents are lost. One accepted document after the rejection avoids it.
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.
All lines are on main 645d4fe14a20, under lucene/core/src/java/org/apache/lucene/.
- Doc IDs are used before validation.
DocumentsWriterPerThread.updateDocumentscallsindexingChain.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 setshasBlockson the segment (:296-299). After a non-aborting exception the used doc IDs are only marked deleted (deleteLastDocs,:303-307). - The parent marker is written in pass 2.
IndexingChain.processDocumentchecks 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. - 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 aBitSetand wraps every sort comparator asin.compare(parents.nextSetBit(docID1), parents.nextSetBit(docID2))(:261-274). NO_MORE_DOCSbecomes a doc ID.BitSet.nextSetBitreturnsNO_MORE_DOCSwhen 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}, sonextSetBit(2)is 2147483647, and the LONG sorter readsvalues[docID]from an array of lengthmaxDoc= 3 (index/IndexSorter.java:308-322). All 13 sort types the hunt tested fail, but not all at the same place:BinarySortFieldfails insideFixedBitSet.get(anAssertionErrorwith-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.- The failure is tragic. An exception from
IndexingChain.flushis aborting (index/IndexingChain.java:301-305):DocumentsWriterPerThread.flushrecords it and fires a tragic event (index/DocumentsWriterPerThread.java:585-589,:594-604), soIndexWritercloses itself and rolls back to the last commit.
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.addDocumentsandupdateDocumentsare tagged@lucene.experimental(IndexWriter.java:1521,:1603,:1616); in the repro only the accepted block usesaddDocuments, the rejected document goes through the non-experimentaladdDocument. - 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 orclose()(with the defaultcommitOnClose) throws,IndexWritercloses 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 newIndexWriteropens 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.
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.
/*
* 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());
}
}
}
}
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()throwsArrayIndexOutOfBoundsException: Index 2147483647 out of bounds for length 3; the writer closes itself and nothing is committed.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.6.0 and 9.10.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (nothing platform-specific) |
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
# 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- Each self-close
IndexWriterdocuments 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_DOCSused as an array index.
The full argument295 words
- Contracts. When
IndexWritercloses itself: the class javadoc, "if you hit an Error, or disaster strikes during a checkpoint then IndexWriter will close itself" (IndexWriter.java:162-165), andgetTragicException, "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 documentIndexWriterrejected 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 overMAX_TERM_LENGTHgives anIllegalArgumentException(:1480-1481). The schema rule a doc-values type conflict breaks is documented inlucene/MIGRATE.md:1557-1567(LUCENE-9334, "Require consistency between data-structures on a per-field basis"), and upstream's test expects anIllegalArgumentExceptionfor it (TestIndexSorting.java:3537). TheDocumentsWriterPerThreadcomment 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.experimentaltag. - Upstream precedent. #15125 ("Handle inconsistent schema on flush with index sorts", merged 2025-09-29, first released in 10.5.1) fixed a
NullPointerExceptionin the samemaybeSortSegmentafter a rejected document. Its testtestFlushAfterInconsistentSchema(TestIndexSorting.java:3497-3556) asserts that a rejected single document followed bycommit()works with an index sort (set in half of its runs), without a parent field. - History. The parent-aware comparator and
setParentFieldcame 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;
testBlockContainsParentFieldrejects 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.
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.
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
intstore 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:checkGoogleJavaFormatpasses;:lucene:core:testwithTestIndexSortRejectedLastDocument1,TestIndexSorting62,TestIndexWriterExceptions40 (3 skipped),TestIndexWriterExceptions21,TestColumnBatchIndexing37 andTestAddIndexes39: 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
-eaand-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 andCheckIndex; the twin finders' repros pass; the refute-lens reviewer's port of the fix passes its own test, includingforceMerge(1).
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.
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.
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.
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
ArrayIndexOutOfBoundsExceptionat flush or commit). - An
IndexWriterAPI 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 theaddBatchpath.
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.
| Where | When | How | Found |
|---|---|---|---|
| Search tool (GitHub issues/PRs, commits, downstream repositories, dev@, java-user@, issues@, commits@, JIRA archive, web) | 2026-10-29 | two 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-29 | 14 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 queries | five open PRs touch the files; none changes the comparator, the marker write or the rejection path |
| Full git history | 2026-10-29 | git log -S 'parents.nextSetBit(docID1)'; commits on main, branch_10x and branch_10_6 for the files involved | only the introducing commit and its backport |
| GitHub, re-check | 2026-10-30 | maybeSortSegment, 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 |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails (gradle; standalone -ea and -da, two seeds) |
| 10.6.0 | affected | the minimal test fails (release jar, -da) |
| 9.10.0 | first release | the minimal test fails (-da) |
| 9.10.0 to 10.6.0 (20 releases) | affected | the 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 express | no setParentField: the test does not compile |
the fix (scratch worktree at 645d4fe14a20) | not affected | the test and 180 tests of the closest suites pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (judge unit) | candidate | Re-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, high | OpenAI 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 | real | Own 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, medium | OpenAI 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 | real | Refuted every intended, misuse and unreachable argument; found the immense-term trigger in a block child. |
| adjudication | confirmed, high | Re-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 fix | Ran 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 436 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,598 words
- 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 withTestIndexSorting,TestIndexWriterExceptions,TestIndexWriterExceptions2,TestColumnBatchIndexingandTestAddIndexes, 180 tests, andcheckGoogleJavaFormat) 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: nosetParentField); 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).
- 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-281a Lucene main checkout (645d4fe14a20) lucene/MIGRATE.md:875-881https://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-30a 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
- TestIndexSortRejectedLastDocument.java (lucene/core, package org.apache.lucene.index, 1 test):
repro/LFV-1050/TestIndexSortRejectedLastDocument.java(49 lines)
A small diagram, made in parallel with this doc from the executed runs.
assets/LFV-1050/bug.png(436 KB)
![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.](../assets/LFV-1050/bug.png)