All findings · Lucene finding · LFV-417 · bpindexreorderer-trailing-orphan-children-aioobe

A segment that ends with child documents and no parent makes each BP reordering merge in which it comes last throw and close the IndexWriter

With BPReorderingMergePolicy, a segment ending in parentless child documents makes each reordering merge in which it comes last throw (with assertions off, at least 64 documents) and close the IndexWriter, again after reopening. A block rejected at a child, after other blocks of its segment and before a commit, suffices.

Summary

Priority
Medium, rank 159An index state that IndexWriter accepted makes every later BP reordering merge throw and close the writer, the same loud index-unusable profile as LFV-244 behind opt-in BP reordering with blocks.
Impact
index unusable
Since
9.11.0 (#13125, 2024)introduced 2024-03-13 · about 2 years
Affected
9.11.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
randomized merge differential test, then code reading

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

BP reordering renumbers the documents of a merged segment so that similar documents sit together. With a parent field it moves each block (children followed by their parent) as a unit, and it assumes the merged documents end with a parent.

Summary card: BPIndexReorderer assumes a merge ends with a parent document; a segment that ends with documents no parent follows makes each reordering merge in which it comes last throw and close the IndexWriter (with assertions off, once the merge has at least 64 documents). Example executed on main: 20 blocks of 4 children + 1 parent, a 21st block rejected at its 3rd child, commit(), forceMerge(1). The parent bits stop at doc 99; nextSetBit(100) is NO_MORE_DOCS, so the bias loop reads biases[103]. Expected: forceMerge(1) completes and the writer stays open. Actual: ArrayIndexOutOfBoundsException, Index 103 out of bounds for length 103 (an AssertionError with assertions on); the IndexWriter closes with a tragic exception and a new writer fails the same way.
Step by step through the executed example: 20 blocks, a 21st block rejected at its third child, the commit that leaves docs 100-102 without a parent, and the merge in which the reorderer's search for the next parent runs past the end of its 103-entry array. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You index 20 blocks of 4 children + 1 parent (100 documents) with setParentField("parent") and new BPReorderingMergePolicy(new LogDocMergePolicy(), new BPIndexReorderer()). Every block ends with its parent: docs 4, 9, ..., 99.
  2. A 21st block, the last before the commit, has a 32,767-byte keyword in its third child. addDocuments throws the documented IllegalArgumentException and the writer stays open. After commit() the segment has 103 documents: the rejected block's docs 100-102 are deleted and none carries the parent marker. CheckIndex reports the index clean.
  3. You call forceMerge(1) and expect a merged segment with your 100 documents; a plain LogDocMergePolicy merge of the same segment works.
  4. Instead the reorderer looks for the parent after doc 100, gets NO_MORE_DOCS, and reads past its 103-entry array: ArrayIndexOutOfBoundsException: Index 103 out of bounds for length 103 (an AssertionError with assertions on).
  5. The IndexWriter closes with a tragic exception and loses uncommitted changes, and a new writer fails the same way on its next such merge. Deleting only the parent of the last block fails the same way one merge later (Index 99 of 99).

Who is affected

Applications that opt into lucene/misc's BPReorderingMergePolicy with BPIndexReorderer, document blocks and a parent field, and no index sort. It needs a segment ending in parentless documents (a block rejected at a child before a flush, or a parent-only delete or update) that comes last in a reordering merge (with assertions off, of at least 64 documents).

Mechanism, terms and numbers710 words

BPReorderingMergePolicy with BPIndexReorderer (lucene/misc) renumbers merged segments and, since 9.11.0, keeps document blocks together when IndexWriterConfig#setParentField is set. BPIndexReorderer assumes that the merged documents end with a parent and never checks it. A segment can end with documents that have no parent after them through calls IndexWriter accepts: a block rejected at a child as the last block before a flush, in an in-memory segment that also holds accepted blocks (its already-indexed documents stay as deleted documents without a parent marker), or a delete or update term that matches only the parent of the segment's last block (a merge then drops the parent). The next reordering merge in which that segment comes last throws ArrayIndexOutOfBoundsException in shuffle() (with assertions off, once the merged view has at least 64 documents; an AssertionError with assertions on), and the IndexWriter closes with a tragic exception, loses uncommitted changes and fails the same way after every reopen. Committed data stays intact and CheckIndex is clean. Opt-in configuration only; released 9.11.0 through 10.6.0 (10.6.0 by reading) and on main, introduced by #13125 (2024).

Terms

  • Document block: documents added together with IndexWriter#addDocuments; the last one is the parent, the others are its children. With IndexWriterConfig#setParentField, IndexWriter marks every parent with a doc value in that field (IndexingChain.java:604, :644-650).
  • BP reordering: BPReorderingMergePolicy wraps a merge policy and asks BPIndexReorderer (recursive graph bisection) for a new document order for each merged segment. Since 9.11.0 it keeps blocks together when a parent field is set (CHANGES 9.11.0, GITHUB#13125).
  • Merged view: for a reordering merge, IndexWriter wraps all merging segments in one reader, deleted documents included, and hands it to the reorderer (IndexWriter.java:5545-5558); the merge drops deleted documents afterwards.

The bug

  1. BPIndexReorderer.computePermutation builds parents = BitSet.of(<parent-field doc values>, maxDoc) (BPIndexReorderer.java:879-882) and reorders all maxDoc documents (:891-893). Nothing checks that document maxDoc - 1 is a parent.
  2. In shuffle(), each block runs from its first document to the next parent: numChildren = parents.nextSetBit(firstChild) - firstChild (:373). For documents after the last parent, nextSetBit returns NO_MORE_DOCS (2147483647), so for the trailing run that starts at doc 100, numChildren is 2147483547.
  3. The loop cumulativeBias += biases[i + j] (:376-377) then runs past the end of biases (one entry per document): ArrayIndexOutOfBoundsException: Index 103 out of bounds for length 103. Once shuffle() runs, this loop comes before any gain comparison, so it needs no qualifying term (no term in the repros reaches the default minDocFreq of 4,096). It only runs when the merged view has at least 64 documents (2 x the default minPartitionSize of 32, :243-246). With assertions on, assert i + numChildren < end in assertParentStructure (:329) fails first, for any size.
  4. IndexWriter treats a failed merge as tragic: tragicEvent(t, "merge") (IndexWriter.java:5109-5113) closes the writer.

How a segment comes to end without a parent

  • A block rejected at a child. DocumentsWriterPerThread.updateDocuments gives each document a doc ID before indexing it (DocumentsWriterPerThread.java:291) and, on a non-aborting exception such as a term longer than IndexWriter.MAX_TERM_LENGTH (32,766 bytes), marks the block's documents so far deleted (:302-307). The parent marker is written only for the block's last document, which is never reached. If this block is the last one before a flush, the segment ends with these deleted documents. The segment must also hold accepted documents: a rejected block alone in its in-memory segment is dropped at flush as fully deleted, and the merge works (executed).
  • A parent-only delete or update. A delete or updateDocuments term that matches only the parent of the segment's last block. The first merge still sees the deleted parent's doc value (deleted documents are in the merged view), so it works and drops the parent; the new segment ends with live children that have no parent after them.

The segment must come last in the merged view. When another segment follows it, that segment's first parent ends the trailing run and the merge completes (executed for both triggers: 200 and 199 documents, assertions on and off); the trailing documents are then treated as part of that segment's first block, which for live children is the search-results hazard upstream already knows.

Exposure and likelihood362 words
  • Who. Opt-in only: lucene/misc BPReorderingMergePolicy with BPIndexReorderer, document blocks, IndexWriterConfig#setParentField, and no index sort (an index sort turns merge-time reordering off, IndexWriter.java:5545-5546). BP reordering is not on by default.
  • Trigger. A segment that ends with parentless documents must come last in a reordering merge. Executed: a forced merge of that segment alone, forceMergeDeletes, a natural LogDocMergePolicy merge with an older segment (Index 199 of 199, Index 203 of 203), and a TieredMergePolicy forceMerge(1) with a larger older segment (Index 603 of 603). Forced merges always reorder; natural merges reorder by default (minNaturalMergeNumDocs 1), and with the javadoc's suggested 2^18 small natural merges skip reordering (code reading). With assertions off the merged view needs at least 64 documents.
  • The rejected-block path needs no delete call. IndexWriter rejects a block with a documented IllegalArgumentException (for example a keyword longer than 32,766 bytes in a child), stays open, and itself marks the block's already-indexed documents deleted. An application that checks term lengths before indexing never gets there; otherwise the timing decides: the block must be the last one before a flush in a segment that also holds accepted blocks. One more block in the same in-memory segment defuses it, and a rejected block alone in its segment is dropped at flush (both executed).
  • Effect. Loud: the merge throws, the IndexWriter closes with a tragic exception and changes since the last commit are lost; a reopened writer fails again on its next such merge, so indexing cannot get past that merge until the segment is merged without reordering or not last. Committed documents and deletes stay unchanged, CheckIndex is clean, and the failure is fast (well under a second on about 100,000 documents: 210 ms and 80 ms in two runs).
  • How likely. A judgement, not measured: rare. A randomized merge test with soft deletes hit it in 4 of 5,000 cases with assertions on; with assertions off the same test hit 0 of 5,000, because the four failing merges had 20 to 62 documents, under the 64-document threshold. Once a segment is in this state, every reordering merge in which it comes last fails (with assertions off, from 64 documents).

Minimal repro

The test shows that with setParentField and BPReorderingMergePolicy(BPIndexReorderer), a block that IndexWriter rejects at a child (immense term) as the last block of a segment makes the next forceMerge(1) fail.

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

import java.io.IOException;
import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.LogDocMergePolicy;
import org.apache.lucene.index.SerialMergeScheduler;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestBPReorderAfterRejectedChild extends LuceneTestCase {

  public void testForceMergeAfterBlockRejectedAtChild() throws IOException {
    IndexWriterConfig iwc =
        new IndexWriterConfig()
            .setParentField("parent")
            .setMergePolicy(
                new BPReorderingMergePolicy(new LogDocMergePolicy(), new BPIndexReorderer()))
            .setMergeScheduler(new SerialMergeScheduler());
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      for (int i = 0; i < 32; i++) {
        w.addDocuments(List.of(new Document(), new Document())); // child, parent
      }
      Document badChild = new Document();
      badChild.add(
          new StringField("f", "x".repeat(IndexWriter.MAX_TERM_LENGTH + 1), Field.Store.NO));
      expectThrows(
          IllegalArgumentException.class,
          () -> w.addDocuments(List.of(badChild, new Document())));
      w.commit();
      try {
        w.forceMerge(1);
      } catch (Throwable t) {
        // Expected: forceMerge succeeds (the bad block was rejected as documented); actual: it throws.
        throw new AssertionError("forceMerge(1) after a block rejected at a child threw " + t, t);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-417/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/misc/build/libs/lucene-misc-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

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

# run 1: assertions on, seed 41700001
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=41700001 \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.misc.index.TestBPReorderAfterRejectedChild
Expected vs actual
Expected: forceMerge(1) succeeds; actual: it throws (AssertionError at BPIndexReorderer.java:329 with -ea, ArrayIndexOutOfBoundsException "Index 65 out of bounds for length 65" at :377 with -da) and closes the writer.

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

More files, environment, full output46 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed 41700001: 1 test(s) run, 1 failed
    => java.lang.AssertionError: forceMerge(1) after a block rejected at a child threw java.lang.AssertionError
       org.apache.lucene.misc.index.TestBPReorderAfterRejectedChild.testForceMergeAfterBlockRejectedAtChild(TestBPReorderAfterRejectedChild.java:57)
     Caused by: java.lang.AssertionError
       org.apache.lucene.misc.index.BPIndexReorderer$IndexReorderingTask.assertParentStructure(BPIndexReorderer.java:329)
       org.apache.lucene.misc.index.BPIndexReorderer$IndexReorderingTask.call(BPIndexReorderer.java:241)
       org.apache.lucene.misc.index.BPIndexReorderer.computePermutation(BPIndexReorderer.java:893)
       org.apache.lucene.misc.index.BPIndexReorderer.computeDocMap(BPIndexReorderer.java:801)
       org.apache.lucene.misc.index.BPReorderingMergePolicy$1.reorder(BPReorderingMergePolicy.java:138)
       [...]

Why it looks unintended

  • IndexWriter accepts every step: it rejects the bad block with the documented IllegalArgumentException and stays open, CheckIndex is clean, and a plain merge works.
  • Short of RAM, BPReorderingMergePolicy "simply skips reordering in order not to fail the merge"; that is the only failure case it documents.
  • With assertions off the failure is an out-of-bounds array read, not a validation error.
The full argument242 words
  • Contracts. BPReorderingMergePolicy: "When reordering doesn't have enough RAM, it simply skips reordering in order not to fail the merge" (BPReorderingMergePolicy.java:35-36). CHANGES 9.11.0: BP "is now supported on indexes that have blocks, as long as they configure a parent field via IndexWriterConfig#setParentField". IndexWriter#addDocument: a term longer than MAX_TERM_LENGTH throws IllegalArgumentException (IndexWriter.java:1480-1481), and after an exception "the index will be consistent, but this document may not have been added" (:1465-1466). IndexWriter#addDocuments limits the effect of deleted block documents to search features: "when these documents are deleted these search features will not work as expected" (:1501-1503). Neither the block docs nor the BP policy docs warn that this reachable state can make a merge fail and close the writer (IndexWriter.merge handles any merge failure as tragic, :5109-5113). The low-RAM promise shows a robustness intent; no general contract says reordering never fails a merge.
  • Behaviour. IndexWriter accepts the rejected block, the delete and the update; core CheckIndex calls the index clean; the same merge without reordering works; deleting the whole block works.
  • Tests. TestBPIndexReorderer.testSingleTermWithBlocks indexes whole blocks and deletes nothing; TestBPReorderingMergePolicy deletes documents but indexes no blocks. No upstream test combines BP reordering, a parent field and deletes or rejected blocks.
  • History. The BP block PRs (#13125, #13128) never discuss deleted parents or segments that end in children; #13128 made IndexWriter reorder only "when it has a chance to preserve the block structure".
The devil's-advocate check346 words

An independent reviewer from another model family (OpenAI GPT-5.6, reading only, could not run Java) built the strongest case for "not a bug": the input is outside the supported block structure. CheckJoinIndex (lucene/join) says "The last document of a segment must always be a parent"; committers have long said that deleting a parent without its children breaks blocks (LUCENE-4068, LUCENE-6660, LUCENE-7674, the #12829 review), and on #14067: "Block-joins require deletes to be consistent across parent and child documents. So I wouldn't call it a bug"; assertParentStructure states the precondition ("Last doc ID must be a parent doc"); the policy promises to skip only when RAM is short; and IndexWriter#addDocuments is marked @lucene.experimental.

Why it fails: on the rejected-block path the application issues no delete call; IndexWriter itself marks the block's already-indexed documents deleted after a documented, non-aborting rejection and leaves the segment ending in them. The application could avoid that only by checking term lengths before indexing, or by the timing of the flush, which it does not normally control. CheckJoinIndex rejects that segment too (executed: "The last document of a segment must always be a parent"), so its invariant is not one IndexWriter maintains; it also rejects a live parent with a deleted child (executed), which addDocuments explicitly supports ("The order of documents within a segment will be preserved, even when child documents within a block are deleted"). Every upstream statement limits the consequence to block-join search results; none says a merge may throw, and @lucene.experimental marks API stability, not a licence for a merge to throw. Lucene asserts check programming errors, not input, and with assertions off the code reads out of bounds. The adjudication (medium confidence), the independent reproducer and the devil's-advocate verifier (moderate-high) rated it a defect; a second cross-model reviewer rated the intended-behaviour case strong for the parent-only path and moderate for the rejected-block path. Verdict: a defect, strongest on the rejected-block path. The parent-only path is much weaker: maintainers can fairly treat CheckJoinIndex's last-parent invariant and the "delete whole blocks" guidance as a precondition of block-aware tooling.

Suggested fix

Recommended Reorder only up to the last parent

Reorder only up to the last parent and leave the parentless documents after it at the end, in their current order (+5/-1 in BPIndexReorderer.computePermutation). The repro passes with assertions on and off; in a 42-block probe every block stays intact and the rest is still reordered; the three closest upstream BP test classes pass (19 tests, nightly tests on).

BitSet parents = null;
int numDocsToReorder = maxDoc;
String parentField = reader.getFieldInfos().getParentField();
if (parentField != null) {
  parents = BitSet.of(DocValues.getNumeric(reader, parentField), maxDoc);
  // Documents after the last parent (e.g. children whose parent was deleted) belong to no
  // block: leave them at the end, in their current order, and reorder the blocks before.
  numDocsToReorder = maxDoc == 0 ? 0 : parents.prevSetBit(maxDoc - 1) + 1;
}
...
IntsRef docs = new IntsRef(sortedDocs, 0, numDocsToReorder); // was: sortedDocs.length
All options and trade-offs4 options ยท 680 words

Two fixes were run through gradle in a scratch worktree at a9779ef3c193 (seed DEADBEEF), a third through the repro harness. To compare them, a probe uses a reorderer that really moves blocks (setMinDocFreq(2), setMinPartitionSize(2), 42 blocks), ends the segment with the 3 parentless children of block 41, runs a BP merge and checks: no failure, every block intact, the parentless documents at the end, the rest still reordered. Stock fails it.

Reorder only up to the last parent (validated by the checks) (recommended)

Documents after the last parent belong to no block: leave them at the end in their current order and reorder the blocks before them. Patch: optionC-reorder-up-to-last-parent.diff, +5/-1.

BitSet parents = null;
int numDocsToReorder = maxDoc;
String parentField = reader.getFieldInfos().getParentField();
if (parentField != null) {
  parents = BitSet.of(DocValues.getNumeric(reader, parentField), maxDoc);
  // Documents after the last parent (e.g. children whose parent was deleted) belong to no
  // block: leave them at the end, in their current order, and reorder the blocks before.
  numDocsToReorder = maxDoc == 0 ? 0 : parents.prevSetBit(maxDoc - 1) + 1;
}
...
IntsRef docs = new IntsRef(sortedDocs, 0, numDocsToReorder); // was: sortedDocs.length

Trade-offs

  • After a parent-only delete the merged segment still ends with those children (as a plain merge leaves it); it no longer breaks later merges.
  • "Every block intact" and "the rest still reordered" are shown by one 42-block probe only; deleted children inside blocks, soft deletes, mixed readers, addIndexes and other parent-field layouts were not tested.
  • No change for segments that end with a parent. No compression or query-speed benchmark, CHANGES entry, javadoc build or full check was run.

What the checks validated

  • The compact repro passes 3 of 3 with assertions on and off.
  • The probe passes: the 3 parentless documents stay last, every block is intact, and the block order changed (25 5 24 4 34 ...).
  • TestBPIndexReorderer (8 tests), TestBPReorderingMergePolicy (3, its nightly tests included) and TestBpVectorReorderer (8) pass; run together with the compact repro (3) and the probe (1): 5 classes, 23 tests, 0 failures, 0 skipped. Assertions off (the repro, the probe and TestBPReorderingMergePolicy): 7 tests, 0 failures. :lucene:misc:checkGoogleJavaFormat passes with the patch and the compact test.
  • Through the repro harness the finder's, the independent reproducer's, the devil's-advocate verifier's and the seven-test rejected-block repro all pass, with assertions on and off.

Skip reordering when the last document is not a parent (the finder's sketch) (validated by the checks)

After building parents, if the last document is not a parent, delete the temporary files and return the current order. Patch: optionA-skip-reordering-finder.diff, +6.

Trade-offs

  • Simplest, but the whole merge loses reordering whenever the last document is not a parent; the identity DocMap still costs the reordering overhead of the merge and marks the segment bp.reordered=true; the postings and forward-index work is done before the check (it could move earlier).

What the checks validated

  • The compact repro passes 3 of 3 (assertions on; off through the harness) and the three upstream classes pass; the probe fails only its last check, "the blocks before the orphans should still be reordered" (block order stays 0 1 2 ... 40). Earlier the finder's run passed 11 of 11 upstream BP tests with nightly tests on.

Treat the last document as a parent (not recommended) (validated by the checks)

parents.set(maxDoc - 1) when it is not set, so the trailing run is one more block (the independent reproducer's shadow fix).

Trade-offs

  • Silently changes block membership, which block-join queries see.

What the checks validated

  • The repros pass, but the probe fails: BP moves the pseudo-block into the middle of the segment, and after the merge a parentless child of block 41 sits inside block 3 ("doc 89 is in the block of parent doc 93 expected:<3> but was:<41>").

Complementary: delete a parent's children with it

An idea from the #12829 review: make a parent delete also delete its children.

Trade-offs

  • Would prevent new parent-only cases, but not the rejected-block path or segments that already end in parentless documents; not a fix on its own.

Downstream note

No downstream product was found that reaches it: Solr sets the parent field only together with an index sort, which turns reordering off, and Elasticsearch and OpenSearch do not use BPIndexReorderer (code reading, nothing run).

How it was found

A randomized differential test of merges, not a formal method: two writers got the same random operations, one with a no-op reorderer and one with BPIndexReorderer. With soft deletes, 4 of 5,000 cases hit the reorderer's parent-structure assertion. Reading the block handling gave the root cause; a later check found the delete-free rejected-block path.

The method in full160 words

Method: randomized merge-level differential testing (the hunt's non-formal tier; the finder tagged it semi-formal), in a hunt unit on misc reordering and index splitting.

  • Two IndexWriters received the same random sequence of adds, block adds, deletes and merges; one merged through a no-op reorderer, the other through the real BPIndexReorderer. With soft deletes, 4 of 5,000 cases (assertions on) failed in assertParentStructure after a soft-deleted parent.
  • Reading BPIndexReorderer's block handling showed the missing last-document check; a deterministic two-session JUnit reproduced it with assertions on and off, on main and on 9.11.0, 9.12.3 and 10.5.2 (9.10.0 passes).
  • An independent reproducer and a devil's-advocate verifier found single-writer variants (forceMergeDeletes after a parent-only delete; updateDocuments by a term only the parent carries). A later reach check, run after the adjudication, found the path with no delete: a block rejected at a child as the last block before a commit.
  • Found 2026-10-26 on main ea57360912ed; re-checked on a9779ef3c193.

Prior reports, history and checks

Prior reports

No prior report found (189 queries, 2026-10-26): GitHub issues, PRs and commits, JIRA, four mailing lists, downstream projects, the web, and the changed files of all 411 open PRs. Known context: committers call parent-only deletes a block-join search hazard (#12829 review, #14067).

Searches4 searches

Verdict new. The nearest items concern the trigger (parent-only deletes) as a search-results hazard, or the code that introduced the defect; none reports a failing merge.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr2026-10-26three runs of the hunt's prior-report tool and 72 GitHub GraphQL searches: class and method names, the assertion messages, orphan or trailing children, parent field with reorder, delete or merge, nextSetBit with NO_MORE_DOCSno report; context: #12829 review and #12711 (parent-only deletes break block search results), #14067 (block joins need consistent deletes), #13125 and #13128 (the BP block PRs, no discussion of deleted parents)
JIRA archive; dev@, java-user@, issues@, commits@2026-10-26list and JIRA queries in each tool runno report; LUCENE-7674 (open), LUCENE-6660, LUCENE-5520, LUCENE-4068: search-time block-join failures on inconsistent blocks
Open apache/lucene PRs2026-10-26changed files of all 411 open PRs; titles of the 493 items updated since 2026-09-01#13186, #15430, #16571 and #16847 touch the files; none changes the parent handling
Web2026-10-269 queriesnothing related

History

Introduced by #13125 "Make BP work on indexes that have blocks" (2024-03-13), first released in 9.11.0. Executed: 9.11.0, 9.12.3, 10.0.0, 10.5.2 and main fail; 9.10.0 passes, because BP ignored blocks there. Not fixed upstream.

Versions tested7 rows

Introduced by #13125 "Make BP work on indexes that have blocks." (@jpountz, merged 2024-03-13 as 3ad73336ae5; branch_9x backport cb318af614b), which added the parents bitset, assertParentStructure and the block loop in shuffle(). A whitespace-insensitive git blame at a9779ef3c193 assigns all three to it (#14913 only re-indented the parents lines). A one-file A/B on the 9.11.0 jars: BPIndexReorderer.java from just before the backport passes, the file from the backport fails. #13128 (same release) only narrows when IndexWriter calls the reorderer.

Not a regression: before 9.11.0 BP reordering ignored blocks entirely, so this is a defect of the block support since its first release. 9.11.1, 9.12.0-9.12.2 and 10.1.0-10.5.1 contain the same lines (code reading). Not fixed upstream: a later main (a95c754ee003, 2026-10-26) has no lucene/misc change, and a read-only check on 2026-10-27 found the same for main f7bcc0abd816. branch_10_6 (10.6.0, released 2026-10-29) and the unreleased branch_10x contain the same block loop and full-range IntsRef with no last-document check (code reading).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193, ea57360912ed affectedcompact repro: 3 tests, 2 failures through gradle, assertions on and off; the finder's repro fails on both commits
10.5.2, 10.0.0, 9.12.3 affectedthe finder's repro fails with assertions on and off (Index 89 of 89); the independent reproducer's tests fail on 10.5.2, 10.0.0 and 9.12.3
9.11.0 affectedfirst affected release: the finder's and the independent reproducer's tests fail with assertions on and off; with the file from before #13125 it passes
9.10.0 not affectedpasses: the crashing code does not exist there (BP ignored blocks)
branch_10_6 (10.6.0, released 2026-10-29), branch_10x (unreleased) affectedby code reading only: the same block loop and full-range IntsRef, no last-document check
9.9.2 and older not affectedcannot express the trigger: no setParentField (compile error)
fix (scratch worktree at a9779ef3c193) not affectedreorder up to the last parent: repro 3 of 3, probe passes, 23 tests in the closest BP classes pass

Independent checks

An independent reproducer and a devil's-advocate verifier reproduced it with their own tests; an adjudication confirmed it (medium confidence) and an audit kept it. A reviewer from another model family, reading only, called the input out of contract; the rejected-block path, found later, needs no inconsistent delete.

Checks and verdicts428 words

Each executing check wrote or re-ran its own JUnit on stock code; every control passed.

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realOwn JUnit (100 documents): 2 of 3 fail with assertions on and off, control passes; found the BP-only forceMergeDeletes path, the natural-merge failure and the 64-document boundary; a probe showed the tragic close, the lost uncommitted delete and a fresh writer failing again; ran 10.5.2, 9.12.3, 9.11.0 (fail) and 9.10.0 (pass).
devil's advocate real, moderate-high that it is not intendedBuilt the strongest misuse case from the join-module history and CheckJoinIndex; found that every source limits the consequence to search features. Single-writer variants (parent-only delete; updateDocuments by a parent-only term) fail 2 of 2 with assertions on and off; whole-block controls pass.
cross-model review (refute lens) not real, high (reading only); root cause agreedAn independent reviewer from another model family (OpenAI GPT-5.6) could only read the code; it agreed on the root cause and the 64-document threshold and argued that the input is out of contract. A separate run re-ran the finder's repro: 1 of 1 failed. The rejected-block path was found after this review.
adjudication confirmed, mediumRe-ran the independent reproduction's tests (2 of 3 fail with assertions on and off) and rejected the out-of-contract argument; medium because maintainers may answer that parent-only deletes are unsupported.
reach check (after the adjudication) found the rejected-block pathA seven-test probe: forced, natural LogDocMergePolicy and TieredMergePolicy merges fail (Index 103, 203, 603) on main, 10.5.2 and 9.11.0; the controls, integrity checks and the fix sketch pass; 9.10.0 passes.
Follow-up, audit and main refresh reproduces; keepHistory, prior reports (new) and the re-runs of the finder's, the independent reproduction's and the rejected-block repros; the audit kept it at the same class; the refresh to a9779ef3c193 reproduced it unchanged.
this doc's runs 3 tests, 2 failures on stock; fix validatedThe compact JUnit through gradle on stock a9779ef3c193 with assertions on and off; two fixes with a fix-property probe and the closest BP classes; a third fix compared through the harness.
fact checks of this doc mechanism, reach, fix and classification hold; wording fixedAn independent fact check re-ran the gradle repro (3 tests, 2 failures, assertions on and off), the harness repros and option C, and ran its own probes (the 63/64-document boundary, merges where the segment is not last, the tragic close, CheckJoinIndex). A cross-model reviewer (reading only) checked the text. Their wording findings are fixed; see an unpublished file.

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

Finding record

LFV-417, snapshot of 2026-11-06

Registry title
BPIndexReorderer assumes every segment ends with a parent document: once a segment ends with child documents that have no parent after them, each BP reordering merge in which that segment comes last throws and closes the IndexWriter
Area
misc / BP merge-time reordering (BPIndexReorderer with document blocks)
Priority
Medium, rank 159: An index state that IndexWriter accepted makes every later BP reordering merge throw and close the writer, the same loud index-unusable profile as LFV-244 behind opt-in BP reordering with blocks.
Impact class
index unusable
Impact note
Applications that opt into the lucene/misc BPReorderingMergePolicy with BPIndexReorderer on an index with document blocks and IndexWriterConfig#setParentField (and no index sort) are hit once a segment ends with child documents that have no parent after them (a delete or update term that matched only the parent of the segment's last block, after a merge has dropped that parent, or a block rejected at a child as the last block before a flush) and that segment comes last in a later reordering merge: the merge throws (AssertionError with -ea; ArrayIndexOutOfBoundsException in shuffle() with -da once the merged view has at least 64 documents), the writer closes with a tragic exception, uncommitted changes are lost and a reopened writer fails again on its next such merge, while committed data stays intact; ordinary scale, released since 9.11.0.
Summary
You index document blocks with IndexWriterConfig#setParentField, merge through BPReorderingMergePolicy with a BPIndexReorderer, and a segment ends up ending with child documents that have no parent after them, for example because you deleted or updated only the parent of its last block and a merge dropped it, or because IndexWriter rejected a block at one of its children (with the documented IllegalArgumentException) just before a flush. You expect later merges to work: IndexWriter accepted every call, CheckIndex calls the index clean and a plain merge of the same segment succeeds. Instead BPIndexReorderer, which assumes the merged view ends with a parent, runs past the end of its arrays in the next reordering merge in which that segment comes last (ArrayIndexOutOfBoundsException with assertions off, AssertionError with them on), and IndexWriter closes with a tragic exception, loses uncommitted changes and fails the same way after every reopen.
Introduced
3ad73336ae5ce05d9c96d32e32502383a4d856b3, apache/lucene#13125 'Make BP work on indexes that have blocks.' (by jpountz, merged 2024-03-13; branch_9x backport cb318af614b) [read: git blame at ea57360912ed gives every line on the failure path (the parents BitSet, the shuffle() block-bias loop, assertParentStructure) to it, and no later commit touched them; exec: the 9.11.0 jars pass with BPIndexReorderer.java from f6d709329ee, the backport's parent, and fail with the file from cb318af614b]; #13128 (4a3f4cfe974, 2024-03-29) only narrows when IndexWriter calls the reorderer and is not the cause; not a regression: before 9.11.0 BP reordering did not keep blocks at all, so this is a defect of the block support since its first release (2024-03-13)
First affected version
9.11.0 (tagged 2024-06-06) [exec]
Affected versions
9.11.0 through 10.6.0 (10.6.0 by reading) and main ea57360912ed (unreleased 11.0.0-SNAPSHOT) [exec: the canonical repro fails with -ea and -da on 9.11.0, 9.12.3, 10.0.0, 10.5.2 and main; the independent reproduction's JUnit fails on 9.11.0, 9.12.3, 10.0.0, 10.5.2 and main; read: 9.11.1, 9.12.0-9.12.2 and 10.1.0-10.5.1 contain 3ad73336ae5 or cb318af614b and the same lines with no last-document guard]; not affected: 9.10.0 [exec: passes, the crashing code is absent there]; 9.9.x and older cannot express the trigger [exec: 9.9.2 does not compile, no setParentField]; 10.6.0 (released 2026-10-29) has the same block loop with no last-document check [read: branch_10_6, BPIndexReorderer.java unchanged since 2024-09-12]; not fixed on upstream main cf398bfb142c (2026-10-26) [read: lucene/misc unchanged since ea57360912ed]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal
Upstream and prior reports
none found (2026-10-26, plus the finder's run and the independent reproduction and the intended-behaviour check searches, all read-only; the prior-report record verdict new, 189 logged queries: three scripted prior-report searches over apache/lucene issues, PRs and commits, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and downstream projects, 72 GitHub GraphQL searches, 9 web queries, plus reads of the changed-file lists of all 411 open apache/lucene PRs and the titles of the 493 items updated since 2026-09-01). Context, not this bug: s1monw in the #12829 and #12711 reviews (2023) called a parent-only delete a gap that breaks block semantics for search results; the join module has old search-time failures on inconsistent blocks (LUCENE-7674 / #8725 open, LUCENE-6660, LUCENE-5520, LUCENE-4068); #14067 records the stance that block joins need consistent deletes; the BP block PRs #13123, #13125 and #13128 never discuss deleted parents or segments that end in children. Open PRs touching the files (#13186, #15430, #16847, #16571) do not change the parent handling. Not fixed on upstream main cf398bfb142c (2026-10-26).
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): the compact JUnit through gradle with assertions on and off, and two fixes with a fix-property probe and the closest BP test classes (2026-10-27); runs by the finder, an independent reproducer, a devil's-advocate verifier, the adjudication, a later reach check, the follow-up and an audit on main ea57360912ed (lucene/misc, IndexWriter, IndexWriterConfig and DocumentsWriterPerThread are unchanged between the two commits; IndexingChain.java changed in #16734, two imports and the dense columnar vector path, with the parent-marker code unchanged and its lines moved down by 2); the release jars 9.9.2, 9.10.0, 9.11.0, 9.12.3, 10.0.0 and 10.5.2, plus a one-file A/B of the introducing commit. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:319-338 (assertParentStructure; 'assert i + numChildren < end' at 329, 'Last doc ID must be a parent doc' at 335), 370-385 (shuffle block-bias loop; nextSetBit at 373, biases read at 377), 878-882 (parents BitSet at 881, no last-document check)
  • lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:35-38 ('it simply skips reordering in order not to fail the merge'), 46 (minNaturalMergeNumDocs = 1)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5544-5558 (merge-time reorder gate: no index sort and blocks identified by a parent field; merged view of all merge readers), 5112 (tragicEvent on merge failure), 3548-3566 (addIndexes(CodecReader...) gate, not executed)
The finding in 30 seconds
  • lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:243-246, 319-338, 370-385, 879-882, 891-893
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5545-5558, 5109-5113
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:283-291, 302-307
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:604, 644-650
  • lucene/CHANGES.txt:2758-2759
  • lucene/misc/src/java/org/apache/lucene/misc/index/AbstractBPReorderer.java:25
Who is affected
  • lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:46, 54-57, 103-105, 133-145
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5545-5546
Why it looks unintended
  • lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:35-38
  • lucene/CHANGES.txt:2758-2759
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1465-1466, 1480-1481, 1499-1503
  • lucene/join/src/java/org/apache/lucene/search/join/CheckJoinIndex.java:49-53, 64-72
  • lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:335
  • lucene/misc/src/test/org/apache/lucene/misc/index/TestBPIndexReorderer.java:139-162; TestBPReorderingMergePolicy.java:87-88
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1510-1511, 1521, 5109-5113
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:316-317; lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:143, 154-155
History
  • a Lucene clone (local clone): git diff a9779ef3c193 origin/main (a95c754ee003) -- lucene/misc is empty; git show origin/branch_10_6 and origin/branch_10x:lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:428, 946

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

Repro files

About the diagrams

A step-by-step picture made in parallel with this doc from a probe run on main a9779ef3c193 (assertions on and off, and with a fix sketch); the values on screen come from the probe's trace.

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.