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.
- You index 20 blocks of 4 children + 1 parent (100 documents) with
setParentField("parent")andnew BPReorderingMergePolicy(new LogDocMergePolicy(), new BPIndexReorderer()). Every block ends with its parent: docs 4, 9, ..., 99. - A 21st block, the last before the commit, has a 32,767-byte keyword in its third child.
addDocumentsthrows the documentedIllegalArgumentExceptionand the writer stays open. Aftercommit()the segment has 103 documents: the rejected block's docs 100-102 are deleted and none carries the parent marker.CheckIndexreports the index clean. - You call
forceMerge(1)and expect a merged segment with your 100 documents; a plainLogDocMergePolicymerge of the same segment works. - 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(anAssertionErrorwith assertions on). - The
IndexWritercloses 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).
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).
- Document block: documents added together with
IndexWriter#addDocuments; the last one is the parent, the others are its children. WithIndexWriterConfig#setParentField,IndexWritermarks every parent with a doc value in that field (IndexingChain.java:604,:644-650). - BP reordering:
BPReorderingMergePolicywraps a merge policy and asksBPIndexReorderer(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,
IndexWriterwraps 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.
BPIndexReorderer.computePermutationbuildsparents = BitSet.of(<parent-field doc values>, maxDoc)(BPIndexReorderer.java:879-882) and reorders allmaxDocdocuments (:891-893). Nothing checks that documentmaxDoc - 1is a parent.- 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,nextSetBitreturnsNO_MORE_DOCS(2147483647), so for the trailing run that starts at doc 100,numChildrenis 2147483547. - The loop
cumulativeBias += biases[i + j](:376-377) then runs past the end ofbiases(one entry per document):ArrayIndexOutOfBoundsException: Index 103 out of bounds for length 103. Onceshuffle()runs, this loop comes before any gain comparison, so it needs no qualifying term (no term in the repros reaches the defaultminDocFreqof 4,096). It only runs when the merged view has at least 64 documents (2 x the defaultminPartitionSizeof 32,:243-246). With assertions on,assert i + numChildren < endinassertParentStructure(:329) fails first, for any size. IndexWritertreats a failed merge as tragic:tragicEvent(t, "merge")(IndexWriter.java:5109-5113) closes the writer.
- A block rejected at a child.
DocumentsWriterPerThread.updateDocumentsgives each document a doc ID before indexing it (DocumentsWriterPerThread.java:291) and, on a non-aborting exception such as a term longer thanIndexWriter.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
updateDocumentsterm 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
BPReorderingMergePolicywithBPIndexReorderer, 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 naturalLogDocMergePolicymerge with an older segment (Index 199 of 199, Index 203 of 203), and aTieredMergePolicyforceMerge(1)with a larger older segment (Index 603 of 603). Forced merges always reorder; natural merges reorder by default (minNaturalMergeNumDocs1), 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.
IndexWriterrejects a block with a documentedIllegalArgumentException(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
IndexWritercloses 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,CheckIndexis 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).
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.
/*
* 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);
}
}
}
}
# 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.
More files, environment, full output46 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)
[...]IndexWriteraccepts every step: it rejects the bad block with the documentedIllegalArgumentExceptionand stays open,CheckIndexis 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 viaIndexWriterConfig#setParentField".IndexWriter#addDocument: a term longer thanMAX_TERM_LENGTHthrowsIllegalArgumentException(IndexWriter.java:1480-1481), and after an exception "the index will be consistent, but this document may not have been added" (:1465-1466).IndexWriter#addDocumentslimits 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.mergehandles 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.
IndexWriteraccepts the rejected block, the delete and the update; coreCheckIndexcalls the index clean; the same merge without reordering works; deleting the whole block works. - Tests.
TestBPIndexReorderer.testSingleTermWithBlocksindexes whole blocks and deletes nothing;TestBPReorderingMergePolicydeletes 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
IndexWriterreorder 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.
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.lengthAll 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.
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.lengthTrade-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,
addIndexesand 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
checkwas 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) andTestBpVectorReorderer(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 andTestBPReorderingMergePolicy): 7 tests, 0 failures.:lucene:misc:checkGoogleJavaFormatpasses 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
DocMapstill costs the reordering overhead of the merge and marks the segmentbp.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.
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>").
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.
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).
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 realBPIndexReorderer. With soft deletes, 4 of 5,000 cases (assertions on) failed inassertParentStructureafter 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 (
forceMergeDeletesafter a parent-only delete;updateDocumentsby 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 ona9779ef3c193.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr | 2026-10-26 | three 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_DOCS | no 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-26 | list and JIRA queries in each tool run | no report; LUCENE-7674 (open), LUCENE-6660, LUCENE-5520, LUCENE-4068: search-time block-join failures on inconsistent blocks |
| Open apache/lucene PRs | 2026-10-26 | changed 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 |
| Web | 2026-10-26 | 9 queries | nothing related |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193, ea57360912ed | affected | compact 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 | affected | the 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 | affected | first 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 affected | passes: the crashing code does not exist there (BP ignored blocks) |
branch_10_6 (10.6.0, released 2026-10-29), branch_10x (unreleased) | affected | by code reading only: the same block loop and full-range IntsRef, no last-document check |
| 9.9.2 and older | not affected | cannot express the trigger: no setParentField (compile error) |
fix (scratch worktree at a9779ef3c193) | not affected | reorder up to the last parent: repro 3 of 3, probe passes, 23 tests in the closest BP classes pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| independent reproducer | real | Own 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 intended | Built 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 agreed | An 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, medium | Re-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 path | A 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; keep | History, 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 validated | The 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 fixed | An 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. |
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 455 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,194 words
- 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 mainea57360912ed(lucene/misc,IndexWriter,IndexWriterConfigandDocumentsWriterPerThreadare unchanged between the two commits;IndexingChain.javachanged 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.
- 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-893lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5545-5558, 5109-5113lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:283-291, 302-307lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:604, 644-650lucene/CHANGES.txt:2758-2759lucene/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-145lucene/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-38lucene/CHANGES.txt:2758-2759lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1465-1466, 1480-1481, 1499-1503lucene/join/src/java/org/apache/lucene/search/join/CheckJoinIndex.java:49-53, 64-72lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:335lucene/misc/src/test/org/apache/lucene/misc/index/TestBPIndexReorderer.java:139-162; TestBPReorderingMergePolicy.java:87-88lucene/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
- TestBPReorderAfterRejectedChild.java (JUnit test):
repro/LFV-417/src/org/apache/lucene/misc/index/TestBPReorderAfterRejectedChild.java(61 lines)
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.
assets/LFV-417/bug.png(455 KB)
![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.](../assets/LFV-417/bug.png)