All findings · Lucene finding · LFV-429 · reorder-merge-ignores-pre99-blocks

Reordering merges break document blocks written before Lucene 9.9, because IndexWriter reads "not recorded" as "no blocks"

With a reordering merge policy like BPReorderingMergePolicy, merging parent/child blocks written by Lucene 9.8 or older pulls children away from their parents (139 of 257 in the repro), silently, with a clean CheckIndex. IndexWriter skips reordering only when a segment reports blocks, and segments written before 9.9 never record that.

Summary

Priority
Low, rank 282Needs the opt-in BP reordering merge policy on never-reindexed document blocks written before 9.9, a combination few deployments have.
Impact
data corruption
Since
9.11.0 as a gap in the block guard (#13128, 2024); 9.9.0-9.10.0 had no guard at allintroduced 2023-11-23 · about 2 years
Affected
9.9.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
code reading while building a cross-version check of indexes written by old releases

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

Lucene keeps a parent and its children (a document block) together through merges. A reordering merge policy renumbers documents, so IndexWriter lets it run only when there are no blocks or a parent field marks them. It learns "no blocks" from a per-segment flag first written in 9.9.0, so older segments always answer no, and their blocks get scrambled.

  1. A shop indexes each product as a block with addDocuments: its variants first, the product last. Lucene 9.8 wrote 4 such blocks (shirt, shoe, hat, sock; 2 color variants each, 12 documents) into 2 segments, with no index sort and no parent field (9.8 has none, and an existing index that already has fields cannot get one).
  2. On a current Lucene you switch to BPReorderingMergePolicy to shrink the index (its size thresholds lowered for this tiny example, minPartitionSize 2 and minDocFreq 1; with the defaults a 12-document merge keeps its order, though the guard decides the same way) and run forceMerge(1). You expect the blocks to survive: IndexWriter skips reordering when "blocks can't be identified using the parent field", and the same index written by 9.9.2 is indeed left alone.
  3. Instead IndexWriter asks each segment whether it holds blocks. Segments written before 9.9 never recorded that, so both answer no, the merge places each document on its own, ignoring blocks, and the shoe's black variant and the sock's white variant end up inside the hat's block.
  4. Block joins pair a variant with the next product, so "products with a white variant" now returns shirt, shoe and hat instead of shirt, shoe and sock: 2 of 8 variants have the wrong parent. In a 377-document repro, 139 of 257 children lost their parent.
  5. No error, CheckIndex is clean, and the merged segment still reports no blocks, so later merges of it stay unprotected; only a reindex (or a copy from before the merge) repairs it. Affected: every release from 9.9.0 to 10.6.0 (10.6.0 by reading) and main.

Who is affected

Applications that opt into a reordering merge policy (BPReorderingMergePolicy or any custom OneMerge#reorder) with no index sort, on an index whose blocks were first written by Lucene 9.8 or older and never reindexed. Every forced merge, natural merge or addIndexes of only such segments gets reordered; a 9.9+ segment holding a block in the same merge protects it.

Mechanism, terms and numbers887 words

Since 9.11.0, IndexWriter lets a merge renumber documents (OneMerge#reorder) only when there are no document blocks or a parent field identifies them. It decides "no blocks" from the per-segment flag LeafMetaData#hasBlocks, which Lucene first recorded in 9.9.0 and which is documented as false for every segment written earlier. So on an index whose blocks were written by Lucene 9.0-9.8, which cannot have a parent field, every reordering merge of only such segments is let through, and BPReorderingMergePolicy interleaves the blocks: 139 of 257 children separated from their parent in the finder's repro, 199 of 200 wrong ToParentBlockJoinQuery parents in an independent one. There is no exception, CheckIndex passes, the merged segment keeps hasBlocks=false, and from 9.11.0 on the same documents written by 9.9.2 are left alone (9.9.0-9.10.0 had no guard at all). It affects every release from 9.9.0 to 10.6.0 (10.6.0 by reading) and main; the fix treats segments whose documents predate 9.9 as possibly holding blocks.

Terms

  • Document block: documents added together with IndexWriter#addDocuments, children first and the parent last. Lucene keeps them adjacent, and block joins (ToParentBlockJoinQuery, ToChildBlockJoinQuery) find a child's parent as the next parent document (ToParentBlockJoinQuery.java:261, parentBits.nextSetBit(childDoc + 1)).
  • Parent field: IndexWriterConfig#setParentField (since 9.10) marks every parent so that a reorderer can move whole blocks. An existing index that already has fields but no parent field cannot get one: "can't add a parent field to an already existing index without a parent field" (IndexWriter.java:1125-1131; only an index with no fields yet can, CHANGES GITHUB#13498/#13340), so an index holding pre-9.9 blocks never can.
  • Reordering merge: a merge policy whose OneMerge#reorder returns a doc map renumbers the merged segment. BPReorderingMergePolicy (lucene/misc) does this to cluster similar documents and shrink the index; it asks to reorder every forced merge ("No minimum size for forced merges", BPReorderingMergePolicy.java:103-105), treats addIndexes as forced (:199-203) and asks to reorder natural merges from 1 document up by default (:46). "Reorder" here means the policy returns a doc map; whether documents actually move depends on BP's size thresholds (BPIndexReorderer.java:243-245), and its bp.reordered=true diagnostic does not prove that any did.

The mechanism (main ea57360912ed)

  1. LeafMetaData#hasBlocks: "Note: This property was not recorded before LUCENE_9_9_0 this method will return false for all leaves written before LUCENE_9_9_0" (LeafMetaData.java:34-38). The 9.0-9.8 segment-info format reads it as false (Lucene90SegmentInfoFormat.java:157-170).
  2. Both reorder guards, for merges (IndexWriter.java:5544-5558) and for addIndexes(CodecReader...) (:3549-3562), compute hasBlocksButNoParentField = anyMatch(LeafMetaData::hasBlocks) && anyMatch(parentField == null) under the comment "Don't reorder if blocks can't be identified using the parent field.", and call merge.reorder(...) when it is false and no index sort is set.
  3. For a merge whose inputs all predate 9.9 the first operand is false, so the reorder runs although the blocks have no parent field. BP places each document on its own, ignoring blocks, so children land before other blocks' parents.
  4. The merged segment's hasBlocks is the OR of its inputs (IndexWriter.java:5313-5319; addIndexes :3500-3506), so it stays false and later merges of it stay unprotected unless another input reports blocks. Its minVersion stays the oldest input's (SegmentMerger.java:78-91), which a fix can key on.
  5. anyMatch means one input that records blocks protects the merge: the 9.8.0 segments plus one main-written 2-document block are not reordered (0 broken), while plus one main-written single document they still are (124 broken). An index sort also disables reordering.
  6. CheckIndex does not notice: its only block-aware check is part of the index-sort test (CheckIndex.java:1247-1256).

The numbers

Small example (written by 9.8.0: 4 product blocks, 2 color variants each, 12 documents, 2 segments), forceMerge(1) through BPReorderingMergePolicy on main, with BP's thresholds lowered for this tiny example (minPartitionSize 2, minDocFreq 1; with the defaults the guard still lets the reorder through and writes bp.reordered=true, but no document moves and all 8 variants keep their parent): the guard computes anyMatch(hasBlocks)=false, the merge is reordered (bp.reordered=true, merged hasBlocks=false, minVersion=9.8.0), and the order becomes [1a, 1b, P1 shirt, 2a, P2 shoe, 4b, 2b, 3a, 3b, P3 hat, 4a, P4 sock]: ToParentBlockJoinQuery maps 2b (the shoe's black variant) and 4b (the sock's white variant) to the hat, "products with a white variant" returns shirt, shoe, hat instead of shirt, shoe, sock, 6 of 8 variants keep their parent, and CheckIndex is clean. Written by 9.9.2, the same blocks are not reordered and all 8 keep their parent; with the fix, the 9.8.0 copy is not reordered either.

Finder's fixture (120 blocks, 377 documents, 257 children, 3 segments, written by 9.8.0), forceMerge(1) through BPReorderingMergePolicy(LogDocMergePolicy, BPIndexReorderer): 139 of 257 children separated from their parent, merged hasBlocks=false; the same documents written by 9.9.2: 0, not reordered. Independent fixture (200 blocks, 584 documents, 384 children): 381 of 384 children outside their block and 199 of 200 wrong ToParentBlockJoinQuery parents, through forceMerge(1) and through addIndexes(CodecReader...); CheckIndex passes. This doc's test with a merge policy that reverses doc IDs: 257 of 257 children paired with the wrong parent, both paths. The BP repros lower minPartitionSize (default 32) and minDocFreq (default 4,096) only so that a few hundred documents (12 in the small example) actually move; the guard decision does not depend on them.

Exposure and likelihood389 words
  • Who is exposed. Applications that opt into a reordering merge policy, BPReorderingMergePolicy from lucene/misc or any MergePolicy whose OneMerge#reorder (@lucene.experimental) returns a doc map, with no index sort, on an index that still holds blocks first written by Lucene 9.0-9.8 and never reindexed. Blocks written by 8.x are hit too on 9.9.0-9.12.3 (executed on 9.10.0, 9.11.0 and 9.12.3; 9.9.x by reading, as it has no guard at all; 10.0+ cannot open 8.x indexes).
  • Trigger. A forced merge, a natural merge, forceMergeDeletes or addIndexes(CodecReader...) whose inputs all predate 9.9 (or all report no blocks). A forced merge right after enabling the policy hits the whole index; BP's defaults then ask to reorder every natural merge too (documents move only where BP's size thresholds allow).
  • Silent and permanent. No exception, CheckIndex clean, the merged segment keeps hasBlocks=false. The merged segment does not record which documents formed a block, so a code fix cannot repair it; affected indexes need a reindex from source (or a restore of a copy taken before the merge). Each document's own contents stay intact.
  • What breaks for users. Block joins attach children to the wrong parents (199 of 200 blocks in one repro): nested search, child retrieval, block-join filters and deletes.
  • Reach (read, not tested downstream). Apache Solr ships a BP merge-policy factory since Solr 9.9.0, bundled Lucene 9.0-9.8 in Solr 9.0-9.4, and sets a parent field only together with an index sort; so Solr collections with nested documents first written by Solr 9.4 or earlier, never reindexed, on which an operator enables the BP factory, fit the pattern. No use of BPReorderingMergePolicy was found in Elasticsearch or OpenSearch; a search there for custom OneMerge#reorder overrides hit an API rate limit and was not completed. The open issue #12665 discusses enabling recursive graph bisection out of the box.
  • How likely. Rare: it needs the opt-in policy plus never-reindexed pre-9.9 blocks plus no index sort. For an index that has all three it happens on every such merge.
  • Spotting damaged segments. BP writes the diagnostic bp.reordered=true; a segment with that diagnostic and a minVersion before 9.9.0 went through a reorder without block protection (the marker says BP returned a doc map, not that documents moved; a reading of the values the runs print, not a tested tool). Other reordering policies write no such marker.

Minimal repro

The test shows that a reordering forceMerge (BPReorderingMergePolicy) tears apart document blocks written by Lucene 9.8.0, because IndexWriter's reorder guard reads the unrecorded pre-9.9 LeafMetaData#hasBlocks as "no blocks" (fixture blocks.9.8.0.zip: 3 child+parent blocks in 2 segments).

TestReorderPre99Blocks.java (JUnit test)63 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.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.LeafReader;
import org.apache.lucene.index.LogDocMergePolicy;
import org.apache.lucene.index.StoredFields;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestReorderPre99Blocks extends LuceneTestCase {

  /** blocks.9.8.0.zip: written by Lucene 9.8.0, 3 blocks (child cN, parent pN), 2 segments. */
  public void testReorderingMergeKeepsPre99Blocks() throws Exception {
    Path path = createTempDir();
    TestUtil.unzip(getClass().getResourceAsStream("blocks.9.8.0.zip"), path);
    BPIndexReorderer reorderer = new BPIndexReorderer();
    reorderer.setMinDocFreq(1);
    reorderer.setMinPartitionSize(2);
    IndexWriterConfig iwc =
        new IndexWriterConfig()
            .setMergePolicy(new BPReorderingMergePolicy(new LogDocMergePolicy(), reorderer));
    try (Directory dir = FSDirectory.open(path)) {
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        w.forceMerge(1);
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        LeafReader leaf = getOnlyLeafReader(r);
        StoredFields stored = leaf.storedFields();
        List<String> blocks = new ArrayList<>();
        for (int doc = 0; doc < leaf.maxDoc(); doc += 2) {
          blocks.add(stored.document(doc).get("id") + stored.document(doc + 1).get("id"));
        }
        Collections.sort(blocks);
        // expected: each child cN still directly before its parent pN; actual: blocks torn apart
        assertEquals(List.of("c0p0", "c1p1", "c2p2"), blocks);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-429/, which holds src/ and fixture-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/backward-codecs/build/libs/lucene-backward-codecs-$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)

# fixture blocks.9.8.0.zip, written by the Lucene 9.8.0 release: lucene-core-9.8.0.jar (Maven Central)
javac -d fixture-classes -cp lucene-core-9.8.0.jar fixture-src/MakePre99BlockIndex.java
java -cp fixture-classes:lucene-core-9.8.0.jar MakePre99BlockIndex fixture-index
(cd fixture-index && rm -f write.lock && zip -q -X ../src/org/apache/lucene/misc/index/blocks.9.8.0.zip *)

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

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:src:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.misc.index.TestReorderPre99Blocks
Expected vs actual
Expected the merged segment's docs, in pairs, to be the blocks [c0p0, c1p1, c2p2]; actual: [c0c1, c2p0, p1p2] (doc order c0 c1 c2 p0 p1 p2), with -ea and -da.

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

More files, environment, full output38 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<[c0p0, c1p1, c2p2]> but was:<[c0c1, c2p0, p1p2]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.misc.index.TestReorderPre99Blocks.testReorderingMergeKeepsPre99Blocks(TestReorderPre99Blocks.java:59)

Why it looks unintended

  • The guard's commit lets IndexWriter reorder only when "either there are no blocks or blocks are identified by a parent field".
  • The same blocks written by 9.9.2 are left alone; only pre-9.9 data, whose flag is documented as "not recorded", is scrambled.
  • addDocuments says "merging will preserve a block", and 9.x indexes stay "fully supported (open and write)".
The full argument279 words
  • The guard's stated intent. #13128's commit message (4a3f4cfe9745): "This updates IndexWriter to only call OneMerge#reorder when it has a chance to preserve the block structure, ie. either there are no blocks or blocks are identified by a parent field." The code comment on both guards: "Don't reorder if blocks can't be identified using the parent field." (IndexWriter.java:3550, :5547).
  • The block guarantee. IndexWriter#addDocuments: "the index does not currently record which documents were added as a block. Today this is fine, because merging will preserve a block." (:1499-1500). IndexWriterConfig#setParentField: "In order to maintain the API guarantee that the document order of a block is not altered by the IndexWriter a marker for parent documents is required." (IndexWriterConfig.java:587-588).
  • Old indexes are supported input. MIGRATE.md:136-145: "indexes created with Lucene 9.x remain fully supported (open and write)"; CHANGES GITHUB#16741: "no reindexing required" (CHANGES.txt:10-11).
  • The asymmetry. Blocks without a parent field written by 9.9.2 are skipped on every release since 9.11.0 (executed): "unsupported" is implemented as "do not reorder", not "scramble".
  • Lucene's own tests rely on the guard. MockRandomMergePolicy reverses doc IDs when no parent field is configured (MockRandomMergePolicy.java:240-247, :266-285), which is safe only because IndexWriter skips the reorder for blocks without a parent field. No upstream test opens a pre-9.9 block index under a reordering merge.
  • History. #12685 (@s1monw, 9.9.0) added hasBlocks; a reviewer, @jpountz, asked to document that indexes written before 9.9 "will return false regardless". #13128 (@jpountz, merged 2024-03-29) built the guard on that flag five months later (#12685 was merged 2023-10-23); the PR has no review discussion and does not mention pre-9.9 segments.
The devil's-advocate check296 words

The devil's advocate built the strongest "documented limitation" case, which the cross-model reviews rated moderate: the LeafMetaData note documents that the flag is false for pre-9.9 leaves; CHANGES GITHUB#13125 (9.11.0) says recursive graph bisection "is now supported on indexes that have blocks, as long as they configure a parent field" (CHANGES.txt:2742-2743), which a pre-9.9 index can never do; the @lucene.experimental addDocuments javadoc warns that "in the future Lucene may merge more aggressively re-order documents ... in which case you may need to fully re-index" (IndexWriter.java:1506-1508) and calls offline re-sorting tools "your own risk"; and with an index sort, blocks without a parent field are rejected only for indexes created with 10.0 or later, while an older index with such blocks is sorted without keeping them together (Sorter.java:192-207).

Why it fails: the LeafMetaData note describes the flag, not the guard, and nothing tells users of BPReorderingMergePolicy (whose javadoc does not mention blocks) that pre-9.9 data is exempt from the guard; the same unsupported configuration with 9.9+ data is handled safely, which is what the guard was added for; the addDocuments javadoc states "Today this is fine, because merging will preserve a block" in the present tense and its "own risk" note names offline tools outside IndexWriter; no CHANGES or MIGRATE entry asks BP users to reindex pre-9.9 block indexes; and index sorting without a parent field never kept blocks together, while here intact blocks are destroyed for an optional compression gain. Verdict: not intended, a gap in the guard. Residual risk: setParentField's javadoc and CHANGES GITHUB#13125 already tie block-safe reordering to a parent field, so upstream may still prefer a documentation note over disabling reordering for every merge that touches a pre-9.9 segment; expect "real but narrow", not "by design".

Suggested fix

Recommended Treat leaves whose documents predate 9.9 as possibly holding blocks (both guards)

In both reorder guards, treat a segment as possibly holding blocks when hasBlocks() is true or its minVersion is before 9.9.0 or unknown: a small helper in IndexWriter. The repro passes, and so do the misc, backward-codecs and core test suites. Cost: block-free merges with pre-9.9 documents are no longer reordered. Damaged indexes still need a reindex.

+  private static boolean mayHaveBlocks(LeafMetaData metaData) {
+    return metaData.hasBlocks()
+        || metaData.minVersion() == null
+        || metaData.minVersion().onOrAfter(Version.LUCENE_9_9_0) == false;
+  }
...
-        readers.stream().map(LeafReader::getMetaData).anyMatch(LeafMetaData::hasBlocks)
+        readers.stream().map(LeafReader::getMetaData).anyMatch(IndexWriter::mayHaveBlocks)
...
-          mergeReaders.stream().map(LeafReader::getMetaData).anyMatch(LeafMetaData::hasBlocks)
+          mergeReaders.stream().map(LeafReader::getMetaData).anyMatch(IndexWriter::mayHaveBlocks)
All options and trade-offs4 options ยท 632 words

Option A was validated in a scratch worktree at ea57360912ed (removed afterwards); B, C and D were not run. Until a fix ships, users should not enable a reordering merge policy on an index whose blocks were first written before 9.9; reindexing into a new index with IndexWriterConfig#setParentField lets BP keep blocks together (CHANGES GITHUB#13125; not executed here).

Treat leaves whose documents predate 9.9 as possibly holding blocks (both guards) (validated by the checks) (recommended)

Add mayHaveBlocks(LeafMetaData) = hasBlocks() || minVersion() == null || minVersion() before 9.9.0, and use it instead of LeafMetaData::hasBlocks in the merge guard and the addIndexes(CodecReader...) guard. A pre-9.9 block index without a parent field is then left alone, exactly like the same layout written by 9.9+.

+  private static boolean mayHaveBlocks(LeafMetaData metaData) {
+    return metaData.hasBlocks()
+        || metaData.minVersion() == null
+        || metaData.minVersion().onOrAfter(Version.LUCENE_9_9_0) == false;
+  }
...
-        readers.stream().map(LeafReader::getMetaData).anyMatch(LeafMetaData::hasBlocks)
+        readers.stream().map(LeafReader::getMetaData).anyMatch(IndexWriter::mayHaveBlocks)
...
-          mergeReaders.stream().map(LeafReader::getMetaData).anyMatch(LeafMetaData::hasBlocks)
+          mergeReaders.stream().map(LeafReader::getMetaData).anyMatch(IndexWriter::mayHaveBlocks)

Trade-offs

  • Block-free merges that contain pre-9.9 documents are no longer reordered (they cannot be told apart from block indexes), so such indexes lose BP's compression until they are reindexed. This is the cost upstream will weigh.
  • Protection persists: a merged segment keeps its oldest input's minVersion, so later merges of it are guarded too, also segments that 9.9.0-9.10.0 merged from pre-9.9 inputs.
  • Uses the deprecated Version.LUCENE_9_9_0; Lucene's build disables deprecation lint, and :lucene:core:check passes.
  • The minVersion() == null branch is only defensive: a leaf of an index created with 7.0 or later must have a minVersion (LeafMetaData.java:58-60, IndexWriter.java:3238-3242).
  • Prevents new damage only; already scrambled segments need a reindex.
  • Edge: a segment written today by a codec whose segment-info format cannot store hasBlocks still reads false with a current minVersion; only test codecs in backward-codecs write such formats.

What the checks validated

  • Through gradle in a scratch worktree at ea57360912ed: this doc's test fails 2 of 4 on stock with assertions on and off (257 of 257 children each time) and passes 4 of 4 with the fix, on and off.
  • With the fix: the whole :lucene:misc:test (315 tests, 9 skipped), TestBPReorderingMergePolicy with -Ptests.nightly=true (3 tests), the whole :lucene:backward-codecs:test (2,343 tests, 184 skipped, including the backward-compatibility indexes) and the whole :lucene:core:test (8,865 tests, 322 skipped), all with assertions on and 0 failures; :lucene:core:check and :lucene:backward-codecs:check -x test (checkGoogleJavaFormat, ecjLint, forbiddenApis) successful. The suite runs used the files before tidy re-wrapped two javadoc paragraphs; the repro and check runs used the final files. A first check run, before tidy, exited 1 (runs/validate.console.txt); the re-run after tidy passed.
  • The finder's BPReorderingMergePolicy test through gradle (with backward-codecs added to lucene/misc's test dependencies): 1 of 2 fails on stock (139), 2 of 2 pass with the fix, assertions on and off.
  • Earlier, as a shadow class of the same guard change: the finder's test (2 of 2), the independent reproducer's (4 of 4), the security probe (7 of 7), a probe, and upstream TestSegmentReordering and TestBPReorderingMergePolicy (also nightly) pass.

Option A plus a visible signal (not run)

Log through infoStream, or record a segment diagnostic, when a reorder is skipped because a leaf predates 9.9, so a BP user can see why nothing is reordered.

Trade-offs

Small; no behaviour change beyond A.

Option A plus an explicit opt-out (not run)

Let the application declare that an old index has no blocks, for example with a setter on BPReorderingMergePolicy, to keep reordering block-free pre-9.9 indexes.

Trade-offs

New API surface; the application then owns the risk.

Documentation only (minimum, not run)

Javadoc on BPReorderingMergePolicy and OneMerge#reorder, plus a MIGRATE/CHANGES note: indexes holding blocks written before 9.9 must be reindexed, with a parent field, before a reordering merge policy is enabled.

Trade-offs

Leaves silent, permanent corruption in place for anyone who misses the note.

Downstream note

After such a merge, an access rule that grants roots and their children through a block join shows other roots' children (159 of 192) and hides some of the root's own (41 of 192), and block-join deletes leave children live (10 of 15). Only the application's configuration triggers it; queries and documents cannot. No concrete exploit is known.

How it was found

Code reading while building a cross-version check of indexes written by old releases: listing metadata that main reads but old releases never wrote singled out hasBlocks, the reorder guard's only block signal. A release-jar differential then broke the blocks of a 9.0.0-written index, and the finder minimised it into a JUnit with 9.8.0 and 9.9.2 fixtures.

The method in full173 words

Method: code reading (a hunt unit, cross-version round trips of indexes written by old releases, 2026-10-26), while building a release-jar differential with content-keyed full dumps.

  • The finder, an AI tool, listed the per-segment metadata that main consults but old releases never recorded. LeafMetaData#hasBlocks, recorded only since 9.9.0, stood out as the only block signal of IndexWriter's reorder guard.
  • The unit's differential then reproduced it: variant T6 (BPReorderingMergePolicy plus forceMerge of release-written segments) reported "BLOCKPARENT broken" for a 9.0.0-written case and was clean for a 10.0.0-written control.
  • The finder minimised it into a public-API JUnit with a 9.8.0 fixture and a 9.9.2 control and validated a guard fix as a shadow class.
  • Then: read-only reviews by an independent reviewer from another model family (OpenAI GPT-5.6, so no cross-model reviewer ran Java), an independent reproducer, a root-cause discriminator, a devil's advocate, an adjudication, a per-release history sweep, a prior-report search, a security assessment and probe, a credibility audit, and this doc's own test and fix validation. No model checker or prover was involved.

Prior reports, history and checks

Prior reports

No prior report found: 143 read-only queries on 2026-10-26 over GitHub, JIRA, four mailing lists, downstream projects and the web, plus the changed files of all 411 open PRs. Closest: #13128 (added the guard), #12685 (documented that the flag is not recorded before 9.9) and CHANGES #13125 (BP on block indexes needs a parent field); none connects them.

Searches5 searches

Every hit about reordering merges, BP, document blocks, hasBlocks or parent fields was read; none is this bug. Related, not reports: #13128, #12685, #13125, #12622 (added the policy), #12665 (open: enable BP out of the box), LUCENE-4068 / #5140 (the addDocuments warning about future reordering merges), open PRs #16571, #16847, #15430 and #16879 (touch the same files, leave the guard as is), open #16885, #16886 and #16887 (document blocks with an index sort, other code) and #16884 by the same reporter (an index-sort range count on multi-valued fields), SOLR-17636 / apache/solr#2641 (Solr's BP factory; reach only). Hunt sibling: LFV-093 (BpVectorReorderer ignores a configured parent field) breaks blocks through the same merge path, with a different trigger and fix site. Limits: a search cannot prove absence, private security@ reports are invisible, and someone else filed four index-sort bugs on 2026-10-21 (#16884-#16887, three of them about document blocks).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene) and downstream repositories2026-10-26two runs of a scripted prior-report search (88 queries, also over the Elasticsearch, OpenSearch and Solr repositories) and 29 manual searches: hasBlocks, hasBlocksButNoParentField, LUCENE_9_9_0 hasBlocks, minVersion hasBlocks, "not recorded", "pre-9.9", BPReorderingMergePolicy, "OneMerge#reorder", "preserve blocks", reorder nested documents, ToParentBlockJoinQuery reorder, with symptoms such as "wrong parent"no report; #13128, #12685, #13125, #12622, #12665 related
Open PRs and recent commits2026-10-26changed files of all 411 open apache/lucene PRs, 8 diffs fetched and grepped for hasBlocks, getParentField, LeafMetaData, reorder( and minVersion (the #16847 diff came back empty; a later read-only check of that PR's patch found none of these terms on a changed line); commits since 2026-09-01 on main, branch_10x and branch_10_6 that touch the three filesno PR changes the guard; #16571, #16847, #15430, #16879 touch the same files
JIRA (LUCENE and SOLR)2026-10-269 REST queriesSOLR-17636 only (Solr's BP factory; reach, not the bug)
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-26the scripted prior-report search's list queries and 8 direct archive queries (graph bisection, BPReorderingMergePolicy, reordering merge, nested documents merge)nothing about the bug
Web2026-10-266 queriesnothing relevant

History

The reorder hook came in 9.9.0 (#12622) with no block check; #13128 (9.11.0, 2024-03-29) added today's guard on a flag that #12685 had documented as never recorded before 9.9. The finder's test fails on all 22 releases 9.9.0-10.5.2 and on main; 9.9.0-9.10.0 break 9.9+ blocks too.

Versions tested9 rows

The reorder hook came with #12622 "Add a merge policy wrapper that performs recursive graph bisection on merge" (@jpountz, merged 2023-11-23; f7cab1645017 on main, 6b1538b33136 on branch_9x; first released in 9.9.0), which checked only that no index sort was configured. The defective guard came with #13128 "Make OneMerge#reorder preserve blocks" (@jpountz, merged 2024-03-29; 4a3f4cfe9745 on main, 2e503b657549 on branch_9x; first released in 9.11.0); git blame puts both guards on it, unchanged since, and it is the only commit between 9.10.0 and 9.11.0 that touches the reorder condition. The flag and its caveat came with #12685 "Record if block API has been used in SegmentInfo" (@s1monw, 2023-10-23; 6677109ee608 on main, 26043819a5b1 on branch_9x; 9.9.0).

Read narrowly, as a gap in the #13128 guard, the bug exists since 9.11.0; on 9.9.0-9.10.0 pre-9.9 blocks broke too, as part of the broader no-guard bug that #13128 fixed for 9.9+ data. The 96 vs 139 difference probably comes from BPIndexReorderer changes between 9.10.0 and 9.11.0 (#13123, #13125); not analysed further.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedthis doc's test fails 2 of 4 through gradle and the runner, assertions on and off; the finder's test fails (139), control passes
later main a9779ef3c193 affectedthe finder's test still fails the same way
upstream main readthe three files unchanged on 2026-10-26; no open PR changes the guard
10.0.0 - 10.5.2 (12 releases) affectedthe finder's test fails (139 of 257), control passes; this doc's test fails 2 of 4 on 10.5.2
9.11.0 - 9.12.3 (6 releases) guard gap from 9.11.0fails (139), control passes; -da the same on 9.11.0 and 9.12.3
9.9.0 - 9.10.0 (4 releases) no guard yetfails (96), and the 9.9.2-written control fails too
9.8.0 and earlier not affectedno reordering-merge API; the test does not compile
writer probe: blocks written by 9.0.0 / 8.11.4 affected9.0.0-written: forceMerge and addIndexes fail on 9.10.0, 9.11.0, 10.5.2 and main; 8.11.4-written: forceMerge fails on 9.10.0, 9.11.0, 9.12.3 (10.0+ cannot open 8.x)
main + fix not affectedthis doc's test 4 of 4; the misc, backward-codecs and core suites pass

Independent checks

An independent reproducer (own fixtures: 381 of 384 children, 199 of 200 wrong parents, also via addIndexes), a root-cause discriminator, a devil's advocate, an adjudication and a credibility audit say real; read-only reviewers from another model family agree with medium confidence. This doc's test fails on stock and passes with the fix through Lucene's build.

Checks and verdicts371 words

No reviewer dissented. The cross-model reviews ran read-only, so no cross-model reviewer executed Java; the refute lens produced no verdict (two attempts). A separate re-run attached to the first review left the fixtures off the classpath and failed with an unrelated NullPointerException, so it is not evidence either way.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateJUnit with 9.8.0 and 9.9.2 fixtures: 139 of 257 children separated on main with -ea and -da, control passes; guard fix as a shadow class passes 2 of 2 and upstream TestBPReorderingMergePolicy.
Cross-model review, intended lens (OpenAI GPT-5.6, read-only) real, mediumRoot cause agrees, inputs in contract, intended-behaviour argument moderate; no Java run.
Independent reproducer realOwn fixtures and JUnit, written without the finder's code: 381 of 384 children outside their block and 199 of 200 wrong ToParentBlockJoinQuery parents through forceMerge(1) and addIndexes(CodecReader...); CheckIndex passes; 10.5.2, 9.12.3, 9.11.0 fail only the pre-9.9 cases, 9.10.0 fails all; own guard fix 4 of 4.
Root-cause lens (OpenAI GPT-5.6, read-only) and executed discriminator real, mediumThe first bad state is the guard's false decision, shared by both entry points. The discriminator shows the flag alone decides (139, 124, 0 broken); with the fix TestSegmentReordering and TestBPReorderingMergePolicy (also nightly) pass.
Devil's advocate real, moderate-highStrongest documented-limitation case built and answered (section above); re-ran the repro with -ea and -da: 139.
Adjudication confirmed, highRe-ran the independent reproduction's test (381 of 384, 199 of 200), the finder's with -da (139) and the independent reproduction's with the fix (4 of 4 pass); duplicate of none.
Security probe out of scope of SECURITY.md7 tests on main (-ea, -da), 10.5.2 and 9.12.3: 3 fail on each, 0 with the fix.
Credibility audit keepRe-ran the finder's test with -ea and -da (139) and with the fix (2 of 2 pass).
This doc's test and fix validation fails on stock, passes with the fixGradle at ea57360912ed: 2 of 4 fail on stock with assertions on and off; with the fix 4 of 4 pass and the whole lucene/misc (315 tests), lucene/backward-codecs (2,343) and lucene/core (8,865) test suites, with :lucene:core:check and :lucene:backward-codecs:check -x test successful.

Sources and provenance

Finding record, Lucene and upstream sources and repro files1,448 words

Finding record

LFV-429, snapshot of 2026-11-06

Registry title
Reordering merges (BPReorderingMergePolicy) silently break document blocks written before Lucene 9.9, because IndexWriter's reorder guard reads the unrecorded LeafMetaData#hasBlocks flag as 'no blocks'
Area
index / IndexWriter merge-time reordering (OneMerge#reorder guard; misc BPReorderingMergePolicy)
Priority
Low, rank 282: Needs the opt-in BP reordering merge policy on never-reindexed document blocks written before 9.9, a combination few deployments have.
Impact class
data corruption
Impact note
Applications that opt into a reordering merge policy (BPReorderingMergePolicy from lucene/misc, or any MergePolicy whose OneMerge#reorder returns a doc map) on an index without an index sort that still holds document blocks written by Lucene 9.0-9.8 (or by 8.x, on 9.9-9.12): every forced merge, natural merge or addIndexes(CodecReader...) whose inputs all predate 9.9 silently interleaves the blocks of the merged segment, permanently and with a clean CheckIndex, so block joins return wrong parents and children afterwards (199 of 200 blocks in one run); ordinary scale (a few hundred documents in the repros), released since 9.9.0; a merge that also includes a 9.9+ segment holding a block is protected.
Summary
You have an index with document blocks (children followed by their parent, added with IndexWriter#addDocuments) that was written by Lucene 9.8 or earlier, open it with a current Lucene, and force-merge it through BPReorderingMergePolicy. You expect the merge to leave the blocks alone, because since 9.11 IndexWriter lets a merge reorder documents only when there are no blocks or a parent field identifies them, and such an old index cannot have a parent field. Instead the guard trusts LeafMetaData#hasBlocks, which was never recorded before 9.9 and reads false for those segments, so the reorderer renumbers the documents one by one and interleaves the blocks (139 of 257 children separated in the finder's run, 381 of 384 in the independent reproduction's), with no exception and a clean CheckIndex, while the same documents written by 9.9.2 are left intact.
Introduced
4a3f4cfe9745 on main and 2e503b657549 on branch_9x (#13128 'Make OneMerge#reorder preserve blocks', by jpountz), which added today's guard hasBlocksButNoParentField = anyMatch(LeafMetaData::hasBlocks) && (some leaf has no parent field) [read: git blame puts IndexWriter.java:3550-3556 and :5547-5553 on 4a3f4cfe9745, unchanged since; it is the only commit between 9.10.0 and 9.11.0 whose diff touches the reorder condition (git log -G)]. The reorder hook itself came earlier with #12622 ('Add a merge policy wrapper that performs recursive graph bisection on merge', jpountz; 6b1538b33136 on branch_9x, f7cab1645017 on main), which called OneMerge#reorder with no block check at all, so the pre-9.9 damage starts there [exec: the candidate JUnit first fails at 9.9.0]. The 'not recorded before 9.9.0' caveat of hasBlocks comes from #12685 (s1monw; 26043819a5b1 on branch_9x, 6677109ee608 on main), four months before the guard. No commit-level bisect: release-level runs plus git ancestry pin both boundaries (2023-11-23 (#12622 merged; first released in 9.9.0, 2023-12-04); 2024-03-29 (#13128 merged, the incomplete guard; first released in 9.11.0, 2024-06-06); both first reached main-line releases in 10.0.0 (2024-10-14))
First affected version
9.9.0 [exec: the pre-9.9 case fails; 9.9.0-9.10.0 have no block guard at all, so 9.9+ blocks break there too]; read as a gap in the #13128 guard, 9.11.0 [exec: the first release where the 9.9.2 control passes and the 9.8.0 case still fails]
Affected versions
all 22 releases from 9.9.0 to 10.5.2 and main ea57360912ed [exec: the unchanged candidate JUnit with -ea on every release, and with -da on 9.9.0, 9.10.0, 9.11.0, 9.12.3, 10.5.2 and main; 96 of 257 children separated on 9.9.0-9.10.0, where the 9.9.2 control fails too (no guard), 139 of 257 from 9.11.0 on, where the control passes]; forceMerge and addIndexes(CodecReader...) both break blocks written by 9.0.0 and by 9.8.0 on 9.9.0-10.5.2 and main, and forceMerge breaks blocks written by 8.11.4 on 9.10.0, 9.11.0 and 9.12.3 [exec, writer probe]; 10.0.0 and later cannot open 8.x indexes, so 8.x-written segments matter only on 9.9.0-9.12.3; not affected: 9.8.0 and earlier, which have no reordering-merge API (the JUnit does not compile on 9.8.0) [exec]; main 645d4fe14a2 [exec 2026-10-29: 1 of 2 fail with -ea and -da, 139 children separated; the 9.9.2 control passes]; branch_10x 657014b3c57 and branch_10_6 4ae946e5a9b (10.6.0, released 2026-10-29) have the same guard files [read 2026-10-29]
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
Upstream and prior reports
none found (2026-10-26, the prior-report record verdict new, read-only: two scripted prior-report searches (88 queries over apache/lucene issues, PRs and commits, the Elasticsearch, OpenSearch and Solr repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive), 29 manual GitHub searches, 9 JIRA REST queries (LUCENE and SOLR), 8 direct mailing-list archive queries, 6 web queries, the changed-file lists of all 411 open apache/lucene PRs with 8 diffs grepped for hasBlocks, getParentField, LeafMetaData, reorder( and minVersion, and the commits since 2026-09-01 on main, branch_10x and branch_10_6 that touch the three files; 143 queries, 28 hits read). Not fixed upstream: the guard is unchanged since #13128 and no open PR changes it. Related, not reports: #13128 (added the guard; no review discussion, no mention of pre-9.9 segments), #12685 (added hasBlocks and, at a reviewer's request, the javadoc note that pre-9.9 leaves return false), #13125 (CHANGES: BP supports blocks 'as long as they configure a parent field'), #12622 (added BPReorderingMergePolicy), #12665 (open: enable BP out of the box), LUCENE-4068 / #5140 (the addDocuments warning that future reordering merges may need a re-index), open PRs #16571, #16847, #15430 and #16879 (touch the same files, leave the guard as is), #16885 and #16886 (document blocks with index sort, other code; closed 2026-10-26, fixed by #16893: ffa9d816eeb on main, afeeeff2d14 on branch_10_6, not on branch_10x as of 657014b3c57) and #16887 (other code; closed 2026-10-30, fixed by #16947: f6e64aa0db3 on main, e5c8f72f00f on branch_11x; not on branch_10_6), SOLR-17636 / apache/solr#2641 (Solr's BP factory; reach only). Gaps: lexical search caps, private reports not visible
Lucene commit tested
main ea57360912ed (commit of 2026-10-24; tested 2026-10-26): this doc's test through gradle in a scratch worktree (stock fails 2 of 4 with assertions on and off; with the fix 4 of 4 pass, and so do the whole lucene/misc (315 tests), lucene/backward-codecs (2,343) and lucene/core (8,865) test suites, with :lucene:core:check and :lucene:backward-codecs:check -x test successful; the worktree was removed afterwards) and through the standalone JUnit runner on main (-ea, -da) and on the 10.5.2 release jars (fail); the finder's BPReorderingMergePolicy test on all 22 releases 9.9.0-10.5.2, on main and on a later main a9779ef3c193 (fails); upstream main read on 2026-10-26 (IndexWriter.java, LeafMetaData.java and BPReorderingMergePolicy.java unchanged).

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3547-3560 (addIndexes(CodecReader...) guard and reorder call), :5544-5555 (mergeMiddle guard and reorder call), :3550 and :5547 (comment 'Don't reorder if blocks can't be identified using the parent field.'), :5313-5319 (merged hasBlocks = OR of the inputs), :1125-1131 (a parent field cannot be added to an existing index), :1499-1508 (addDocuments javadoc: 'Today this is fine, because merging will preserve a block'; future reordering merges may need a full re-index)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/LeafMetaData.java:34-38 (hasBlocks 'was not recorded before LUCENE_9_9_0'; false for all leaves written before 9.9.0)
  • a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:34-62 (class and constructor javadoc, no mention of blocks), :46 (minNaturalMergeNumDocs = 1), :103-104 (no minimum size for forced merges), :133-155 (reorder calls computeDocMap; bp.reordered diagnostic)
  • a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:103 (DEFAULT_MIN_DOC_FREQ = 4096) and AbstractBPReorderer.java:25 (DEFAULT_MIN_PARTITION_SIZE = 32)
  • a Lucene main checkout lucene/MIGRATE.md:136-145 (Relaxed Index Upgrade Policy: 9.x indexes remain fully supported, open and write); lucene/CHANGES.txt:10 (GITHUB#16741), :2742-2743 (GITHUB#13125: BP supported on indexes with blocks as long as they configure a parent field)
  • a Lucene main checkout SECURITY.md:28 (downstream applications handle untrusted input), :42 (configuration values are trusted as given), :50 (in-scope reports)
The finding in 30 seconds
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1125-1131, :3500-3506, :3549-3562, :5313-5319, :5544-5558
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/LeafMetaData.java:34-38; lucene/core/src/java/org/apache/lucene/index/SegmentMerger.java:78-91; lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1247-1256
  • a Lucene main checkout lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene90/Lucene90SegmentInfoFormat.java:157-170
  • a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:46, :103-105, :133-155, :199-203; BPIndexReorderer.java:103; AbstractBPReorderer.java:25
  • a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinQuery.java:261
Who is affected
  • a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:46, :103-105, :154-155
Why it looks unintended
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1499-1500, :1506-1517, :3550, :5547; IndexWriterConfig.java:587-588; Sorter.java:192-207
  • a Lucene main checkout lucene/MIGRATE.md:136-145; lucene/CHANGES.txt:10-11, :2742-2743
  • a Lucene main checkout lucene/test-framework/src/java/org/apache/lucene/tests/index/MockRandomMergePolicy.java:240-247, :266-285
Suggested fix
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/SegmentMerger.java:78-91; build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/JavacConfigurationPlugin.java:70 (-Xlint:-deprecation)
  • a Lucene main checkout lucene/CHANGES.txt:2742-2743 (GITHUB#13125)

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

Repro files

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.