All findings · Lucene finding · LFV-093 · bpvectorreorderer-ignores-parent-blocks

Merges through BPReorderingMergePolicy with BpVectorReorderer break document blocks even when a parent field is set

With a parent field set, a merge through BPReorderingMergePolicy with BpVectorReorderer moves children and parents separately: in a 4-block index all 8 children end up before parent b0p, so block joins give 6 of them the wrong parent. No error, CheckIndex passes; main only, unreleased.

Summary

Priority
Medium, rank 100Every merge that BPReorderingMergePolicy with BpVectorReorderer reorders silently scrambles document blocks, so block joins return wrong parents, but it is unreleased (main only) and an expert setup with no known user.
Impact
silent wrong results
Since
#14097 (main, 2025-01-07)introduced 2025-01-07 · about 1 year
Affected
main only (unreleased 11.0.0); no release
Status
documented
Found by
code reading (IndexWriter's merge-time reorder path)

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

A block is children followed by their parent, and a block join finds a child's parent as the first parent after it. IndexWriter lets a merge reorder documents only when a parent field identifies the blocks, then applies the merge policy's map without checking it.

  1. Set setParentField("_parent"), no index sort, and the merge policy new BPReorderingMergePolicy(new LogDocMergePolicy(), new BpVectorReorderer("vec")) with default settings. Add 4 blocks with addDocuments, each two children with a vector and a parent without one, in two segments: [b0c0 b0c1 b0p b1c0 b1c1 b1p] and [b2c0 b2c1 b2p b3c0 b3c1 b3p].
  2. Run forceMerge(1). You expect every block to stay together, parent last: the parent field exists so that IndexWriter never alters the order of a block, and IndexWriter lets a merge reorder documents only because the parent field lets it keep blocks.
  3. Instead the merged order is b0c0 b0c1 b1c0 b1c1 b2c0 b2c1 b3c0 b3c1 b0p b1p b2p b3p. BpVectorReorderer never looks at the parent field, and it puts every document without a vector after all documents that have one.
  4. So ToParentBlockJoinQuery returns b0p for all 8 children (6 wrong), and a nested kNN query for the best child of 3 parents returns 1 hit instead of 3. With the vectors on the parents instead, all 8 children land after the last parent and have no parent at all.
  5. No exception, with assertions on or off, and CheckIndex passes. The scramble is written into the merged segment; reindexing the affected blocks repairs it.

Who is affected

Applications on main that opt into BPReorderingMergePolicy with BpVectorReorderer and index document blocks with a parent field and no index sort. With vectors on the children or the parents, every merge the policy reorders (forced, and at default settings natural too) breaks blocks, from 4 blocks on; with vectors on every document, only merges of 64+ vectors.

Mechanism, terms and numbers818 words

IndexWriter lets a merge renumber documents on an index with document blocks only when a parent field identifies the blocks, because then the reorder can keep each block together; it applies whatever doc map the merge policy returns without checking it. BPReorderingMergePolicy with BpVectorReorderer returns a map that moves every document on its own: the vector bisection never reads the parent field, and documents without a vector are moved after all documents that have one. So a reordered merge silently scrambles the merged segment's blocks: with vectors on the children only or on the parents only, at default settings and at any scale (4 blocks suffice); with vectors on every document, only merges with at least 64 vectors (twice the default minPartitionSize), where the bisection runs, can break them (66 documents: 23 children wrong). Block joins then return another block's parent or none, nested kNN returns fewer hits, and deletes that locate children through a block join miss them; CheckIndex passes, and only reindexing the affected blocks reliably repairs it. The defect came with #14097 (2025-01-07) and exists only on main, so a fix before 11.0.0 keeps it out of every release.

Terms

  • Document block: documents added together with IndexWriter#addDocuments, children first, parent last. Block joins (ToParentBlockJoinQuery, DiversifyingChildrenFloatKnnVectorQuery, ...) find a child's parent as the first parent document after it, so the order inside a segment is the only link between them.
  • Parent field (IndexWriterConfig#setParentField): IndexWriter marks every parent with it. Its javadoc: "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."
  • Merge-time reordering: a merge policy can return a doc map from OneMerge#reorder, and IndexWriter writes the merged segment in that order. BPReorderingMergePolicy does this with an IndexReorderer: BPIndexReorderer (by postings) or BpVectorReorderer (by vector similarity, recursive graph bisection).

The bug

  1. IndexWriter.mergeMiddle skips the reorder only if an index sort is set, or if some merging segment has blocks while some merging segment (not necessarily the same one) has no parent field (IndexWriter.java:5544-5555, added by #13128 "Make OneMerge#reorder preserve blocks"). Otherwise it calls merge.reorder(...) (:5558) and applies the map with SortingCodecReader.wrap(mergedView, docMap, null) (:5576). Nothing checks the map against the blocks.
  2. BPReorderingMergePolicy forwards the call to reorderer.computeDocMap(...) (BPReorderingMergePolicy.java:133-145).
  3. BpVectorReorderer.computeDocMap (BpVectorReorderer.java:578-593) permutes the vector ordinals and never reads the parent field; the file contains neither "parent" nor "block". The bisection splits a range only when each half would hold at least minPartitionSize vectors (default 32, :208-211); when it runs it may move any vector document anywhere in the range, so blocks can interleave, depending on the vectors.
  4. valueMapToDocMap (:735-789, "sort docs with no value at the end") gives every document without a vector a new ID after all documents with one. When only the children (or only the parents) carry the vector, all parents end up after all children (or before them), whatever the vector values and even when the bisection does not run. When every document has a vector, nothing is moved to the end, and below 64 vectors (default settings) the bisection does not run either: the map is the identity (:737-738) and the blocks survive.
  5. The sibling BPIndexReorderer reads the parent field and moves whole blocks (BPIndexReorderer.java:376-382, :450-455, :878-882, #13125). BpVectorReorderer came later (#14097) without that.

The numbers

4 blocks [bNc0, bNc1, bNp], 2-dimensional vectors, two segments, forceMerge(1), default reorderer settings, vectors on the children or on the parents, main ea57360912ed, assertions on and off:

vectors onmerged orderparent a block join finds
children onlyb0c0 b0c1 b1c0 b1c1 b2c0 b2c1 b3c0 b3c1 b0p b1p b2p b3pb0p for all 8 children: 6 wrong
parents onlyb0p b1p b2p b3p b0c0 b0c1 b1c0 b1c1 b2c0 b2c1 b3c0 b3c1none for all 8 children

On the first index, ToParentBlockJoinQuery returns b0p for every child and DiversifyingChildrenFloatKnnVectorQuery with k=3 returns 1 hit (b2c0); with the fix, every child gets its own parent and the kNN query returns 3 hits. CheckIndex passes on the scrambled index.

Wider cases (independent verifiers, executed on main)

  • 30 blocks of 1-3 children (minPartitionSize 2, three segments): ToParentBlockJoinQuery wrong for 59 of 60 children with child-only vectors and 56 of 60 with vectors on every document.
  • 16 blocks, default settings, child-only vectors: 15 of 16 tested children joined to b0p; diversified kNN with k=3 returned 1 hit.
  • A variant with minPartitionSize 2 broke 64 of 64 blocks with vectors on children only, on parents only and on every document.
  • Vectors on every document, default settings (fact check): 4, 16 and 21 blocks (up to 63 documents) keep every child with its parent; 22 blocks (66 documents) give 23 children the wrong parent, 32 blocks (96 documents) 24.
Exposure and likelihood376 words
  • Who is exposed. Applications that combine BPReorderingMergePolicy with a BpVectorReorderer (lucene/misc, expert), document blocks with IndexWriterConfig#setParentField, and no index sort (with an index sort IndexWriter never reorders). The natural user is a nested-vector index: parents with child "chunk" documents that carry embeddings, searched with DiversifyingChildrenFloatKnnVectorQuery and ToParentBlockJoinQuery. That is the layout that breaks deterministically; the join module's own test notes "only child documents are allowed to have a vector!" for its nested kNN queries.
  • Default or opt-in. Opt-in. Inside that setup, with vectors on the children or on the parents, nothing else is needed: default reorderer settings, no explicit codec (the runs drew randomized test codecs), assertions on or off.
  • Scale. With vectors on the children or on the parents: none, 4 blocks (12 documents) in 2 segments, and the result is deterministic. With vectors on every document and default settings, the merge must hold at least 64 vectors (66 documents broke, 63 did not), and where each child lands depends on the vectors (56 of 60 children wrong in a 30-block run with minPartitionSize 2).
  • Frequency. Whenever the policy applies a doc map to a segment with blocks. Forced merges were executed; by code reading the policy also reorders natural, full-flush and forced-deletes merges that reach minNaturalMergeNumDocs and the biggest-segment ratio (defaults 1 and 0, so every such merge; the javadoc suggests 262,144 documents for natural merges). No map is applied when the reorderer exceeds its RAM budget, when the merge has no float vector field of that name, or when the IndexWriter gate refuses. addIndexes(CodecReader...) passes the same IndexWriter gate but was not executed.
  • What you see. Nothing at write time: no exception, CheckIndex passes (its only parent-aware order check sits in the index-sort test, and reordering never happens with an index sort). At query time block joins return another block's parent or none, nested kNN returns fewer hits, and a delete that finds a root's children through a block join leaves them live. The scramble is persisted; by code reading later merges do not undo it, and reindexing the affected blocks repairs it; documents deleted before the merge stay deleted.
  • Versions. Main only, since #14097 (2025-01-07); CHANGES.txt lists it under 11.0.0. Fixing it before 11.0.0 keeps it out of every release.

Minimal repro

The test shows that forceMerge through BPReorderingMergePolicy with BpVectorReorderer breaks document blocks even though IndexWriterConfig#setParentField is set (2 blocks [child with vector, parent], one segment each).

TestBpVectorMergeKeepsBlocks.java (JUnit test)70 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.util.ArrayList;
import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.KnnFloatVectorField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.LogDocMergePolicy;
import org.apache.lucene.index.StoredFields;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestBpVectorMergeKeepsBlocks extends LuceneTestCase {

  public void testForceMergeKeepsBlocks() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig config =
          new IndexWriterConfig()
              .setParentField("_parent")
              .setMergePolicy(
                  new BPReorderingMergePolicy(
                      new LogDocMergePolicy(), new BpVectorReorderer("vector")));
      try (IndexWriter writer = new IndexWriter(dir, config)) {
        for (int block = 0; block < 2; block++) {
          Document child = new Document();
          child.add(new StringField("id", "c" + block, Field.Store.YES));
          child.add(new KnnFloatVectorField("vector", new float[] {1, block + 1}));
          Document parent = new Document();
          parent.add(new StringField("id", "p" + block, Field.Store.YES));
          writer.addDocuments(List.of(child, parent));
          writer.commit();
        }
        writer.forceMerge(1);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        StoredFields storedFields = reader.storedFields();
        List<String> order = new ArrayList<>();
        for (int doc = 0; doc < reader.maxDoc(); doc++) {
          order.add(storedFields.document(doc).get("id"));
        }
        // expected: each child directly before its own parent; actual: [c0, c1, p0, p1]
        assertTrue(
            "expected blocks [cN, pN] kept together after the merge, got " + order,
            order.equals(List.of("c0", "p0", "c1", "p1"))
                || order.equals(List.of("c1", "p1", "c0", "p0")));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-093/, 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 DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.misc.index.TestBpVectorMergeKeepsBlocks
Expected vs actual
Expected each child directly before its own parent, e.g. [c0, p0, c1, p1]. Actual: [c0, c1, p0, p1], so c1 now belongs to p0's block.

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

More files, environment, full output43 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 blocks [cN, pN] kept together after the merge, got [c0, c1, p0, p1]
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.apache.lucene.misc.index.TestBpVectorMergeKeepsBlocks.testForceMergeKeepsBlocks(TestBpVectorMergeKeepsBlocks.java:63)

Why it looks unintended

  • setParentField exists "to maintain the API guarantee that the document order of a block is not altered by the IndexWriter"; addDocuments: "merging will preserve a block".
  • IndexWriter reorders a merge only when the parent field lets the reorder keep blocks (#13128), and the sibling BPIndexReorderer keeps them (#13125).
  • The introducing PR never discusses blocks.
The full argument316 words
  • The contracts. IndexWriterConfig#setParentField (IndexWriterConfig.java:580-588): the parent marker is required "to maintain the API guarantee that the document order of a block is not altered by the IndexWriter". IndexWriter#addDocuments (IndexWriter.java:1499-1500): "the index does not currently record which documents were added as a block. Today this is fine, because merging will preserve a block." Here IndexWriter itself runs the reorder, inside a merge it schedules.
  • The gate. #13128 ("Make OneMerge#reorder preserve blocks", 2024-03-29) changed 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", and made the test framework's MockRandomMergePolicy reverse whole blocks when a parent field is set (MockRandomMergePolicy.java:268-300).
  • The sibling and CHANGES. 9.11.0 CHANGES (lucene/CHANGES.txt:2742-2743): "GITHUB#13125: Recursive graph bisection is now supported on indexes that have blocks, as long as they configure a parent field". BpVectorReorderer's javadoc calls it an "Implementation of recursive graph bisection ... See BPIndexReorderer". TestBPIndexReorderer.testSingleTermWithBlocks asserts complete, parent-last blocks. That entry concerns BPIndexReorderer, the only reorderer at the time, so it shows the design intent rather than a rule for every later reorderer; the setParentField sentence and the #13128 gate are the direct evidence.
  • Intent of #14097. Its PR body: "refactored to make BpVectorReorderer compatible with BPIndexReorderer so that either can plug into BPReorderingMergePolicy". None of its 33 texts (body, reviews, comments) mentions parent, child, nested, join, setParentField or hasBlocks; the 3 hits for "block" are "should not block merging". Upstream's TestBPReorderingMergePolicy picks BpVectorReorderer at random but never uses blocks or a parent field.
  • History repeats. On 9.10.0 the same policy with the postings reorderer broke blocks the same way ("child b4c1 ended up in the block of parent b10p"); 9.11.0 fixed that with #13125 and #13128 (executed). #14097 let a second reorderer into the gated path without the same handling.
The devil's-advocate check367 words

The strongest case for "intended" (an OpenAI GPT-5.6 reviewer with an "intended" lens, and the devil's-advocate verifier): neither the OneMerge#reorder javadoc (@lucene.experimental) nor the IndexReorderer interface (no such tag, like BpVectorReorderer and BPReorderingMergePolicy) says a reorderer must keep blocks; the setParentField javadoc calls the field "optional for all indices that don't use document blocks in combination with index sorting", and MIGRATE says it must be configured "only if index sorting is used together with documents blocks"; addDocuments warns that "in the future Lucene may merge more aggressively re-order documents ... in which case you may need to fully re-index" and that offline re-sorting tools "will likely break them up. Use such tools at your own risk!"; the BPReorderingMergePolicy javadoc still names only BPIndexReorderer; and moving documents without a vector to the end is deliberate and tested (TestBpVectorReorderer.testIndexReorderSparse).

Why it fails: the block duty is stated at the IndexWriter level, and the #13128 gate exists only so that a reorder runs when blocks can be kept; a reorderer that ignores the parent field defeats it. The "future" sentence and the offline-tool NOTE come from LUCENE-3112 (2011, the commit that added addDocuments), long before the parent field (2024). The NOTE is about offline tools; the "future" sentence does foresee merges that reorder documents for compression, which is what this policy does, so the javadoc pulls two ways. What settles it: #13128 later made merge-time reordering conditional on the parent field precisely so that blocks are kept. The "optional ... with index sorting" sentence and the MIGRATE note came with #12829 (2024-01-11), before #13128 (2024-03-29) made the same field the condition for reordering an index with blocks at all. (The NOTE does cover BpVectorReorderer's offline reorderIndexDirectory tool.) The policy javadoc is stale, not a restriction: #14097 plugged the vector reorderer into this policy on purpose. Sparse ordering is fine without blocks; with blocks it is exactly what separates parents from children. @lucene.experimental marks an unstable API, not permission to return wrong block-join results. The "intended" lens rated the case moderate in its record and "weak" in its notes; the refute lens, the independent reproducer, the devil's advocate and the adjudication rejected it.

Suggested fix

Recommended A: do not reorder segments that have blocks

Stop BpVectorReorderer from reordering segments that have blocks: computeDocMap returns null when the segment has blocks (+8 lines). The test, the closest suites and the whole misc module pass with assertions on and off. The cost: no vector reordering for nested indexes; a block-aware reorderer would keep it, but is untested.

if (vectorScore == null) {
  return null;
}
if (reader.getMetaData().hasBlocks()) {
  // This reorderer moves documents one by one, which would break document blocks (children
  // followed by their parent), so leave segments that have blocks in their current order.
  return null;
}
All options and trade-offs4 options ยท 800 words

One fix was built and validated in a scratch worktree at ea57360912ed (removed afterwards); the other options come from the adjudication, the verifiers and this doc and were not run.

A: do not reorder segments that have blocks (validated by the checks) (recommended)

In BpVectorReorderer.computeDocMap, return null (no reordering) when reader.getMetaData().hasBlocks(), and say so in the class javadoc. +8 lines in BpVectorReorderer.java, no API change.

if (vectorScore == null) {
  return null;
}
if (reader.getMetaData().hasBlocks()) {
  // This reorderer moves documents one by one, which would break document blocks (children
  // followed by their parent), so leave segments that have blocks in their current order.
  return null;
}

Trade-offs

  • Feature loss. Vector reordering is switched off for every segment with blocks, which is the nested-vector layout most likely to want it; those segments keep their merge order, and bp.reordered says false.
  • Offline tool. For indexes without a parent field, reorderIndexDirectory then copies segments with blocks unchanged (SortingCodecReader.wrap accepts a null map) instead of scrambling them (executed). On an index with a parent field the tool already fails before reordering, with and without the fix (IllegalArgumentException: its writer has no parent field), and the run left an empty commit: a possible separate defect, not triaged and not part of this report.
  • Blind spot it inherits. It relies on LeafMetaData#hasBlocks, which segments written before 9.9 do not record. Those indexes cannot have a parent field; that case is a separate bug in the IndexWriter gate (LFV-429) and is not fixed here.

What the checks validated

  • Stock: the test fails 2 of 2 through gradle, assertions on and off.
  • With the patch: the test passes 2 of 2, assertions on and off; TestBpVectorReorderer, TestBPReorderingMergePolicy and TestBPIndexReorderer: 19 tests, 0 failures, 2 skipped (@Nightly); the first two with -Ptests.iters=20 (20 draws of the policy test's randomized reorderer choice; the log does not record which reorderer each drew): 220 tests, 0 failures, 40 skipped. The policy test's only reorder-on-merge tests are @Nightly; with -Ptests.nightly=true -Ptests.iters=5 the three suites give 95 tests, 0 skipped, 0 failures (fact check).
  • :lucene:misc:test: 24 classes, 317 tests, 0 failures, 9 skipped, with assertions on and off; :lucene:misc:check -x test passes.
  • Standalone harness with the patched class: the block-join variant (0 of 8 wrong, 3 kNN hits; with this doc's fix/src in the fact check, and with the finder's code-identical sketch earlier), the devil's advocate's layout variants (4/4) and block-join symptom test (1/1), and the security probe (7/7) pass. The independent reproducer's test fails 2 of 4 only on its own precondition that the merge was reordered (expected:<[tru]e> but was:<[fals]e>), as expected for a fix that skips reordering.
  • Not executed with the patch: addIndexes(CodecReader...). By code reading it is covered too: the merged view ORs hasBlocks over its inputs (SlowCompositeCodecReaderWrapper.java:78-94) and addIndexesReaderMerge skips a null map (IndexWriter.java:3561-3565).

B: make BpVectorReorderer block-aware (untested)

Partition whole blocks instead of documents, as BPIndexReorderer does: give all vectors of a block the block's bias (or use one representative per block, such as the centroid of its vectors), sort with blocks as units, and keep documents without a vector inside their block.

Trade-offs

Keeps vector reordering for nested indexes, but it is real work: both the bisection and the "no value at the end" step must change, blocks without any vector need a rule, and ordering quality would need a benchmark.

C: IndexWriter checks the reorder map (untested, defense in depth)

In IndexWriter.mergeMiddle and addIndexesReaderMerge, when a parent field is set and the merged view has blocks, verify the returned map against the live documents: every live child must keep the same first live parent after it, and the live documents of a block must stay together and in order; otherwise ignore the map or throw. It also needs a rule for trailing children without a parent, which can occur (LFV-417). The simple predicate "for each non-parent document d, oldToNew(d) + 1 == oldToNew(d + 1)" is enough only for complete blocks without deletions: moving a deleted child breaks it without harm, a block whose parent is deleted can pass it and still leave its live children orphaned, and a trailing child has no d + 1.

Trade-offs

Protects every OneMerge#reorder implementation, custom ones too, for O(maxDoc) per reordered merge. Ignoring the map hides a reorderer's bug; throwing fails the merge. A core change, so it needs core review; combine it with A or B rather than replacing them.

D: document the limitation (not recommended)

State in the BpVectorReorderer and BPReorderingMergePolicy javadoc that indexes with blocks are not supported.

Trade-offs

Leaves IndexWriter breaking its own setParentField guarantee without any error. Either way, the BPReorderingMergePolicy javadoc should stop naming only BPIndexReorderer.

Downstream note

Once a parent field is set IndexWriter trusts the reorder map, and block joins find a child's root only by position, so children moved by the merge fall under other roots: an application that extends a root's visibility or delete to its children with ToChildBlockJoinQuery then reaches the wrong children. Affected indexes must be reindexed.

How it was found

Code reading. While a hunt unit modelled the doc maps that index sorting builds with document blocks, the finder, an AI tool, read every place where IndexWriter applies a doc map and saw that the merge-time reorder path has no block check and that BpVectorReorderer ignores the parent field. A JUnit on the stock jar confirmed it.

The method in full139 words

Method: code reading (the hunt's non-formal tier).

  • A hunt unit (index sorting, 2026-10-25) was building an Alloy model of the doc maps that index sorting builds with document blocks. Reading every place where IndexWriter applies a doc map, the finder saw that mergeMiddle applies SortingCodecReader.wrap(mergedView, docMap, null) with no block check, and that BpVectorReorderer never reads the parent field. A JUnit on the stock jar confirmed it. The Alloy model has no reorderer and gave no signal for this bug.
  • Before the candidate was written, an independent reviewer from another model family (OpenAI GPT-5.6, refute lens) reproduced it with its own 48-block test. Then an independent reproducer, a second GPT-5.6 reviewer with an "intended" lens, a devil's advocate and an adjudication confirmed it, and later stages bisected the introducing commit, searched for prior reports and probed the security angle.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26): 82 recorded searches and checks over GitHub, commits, downstream code, four mailing lists, the JIRA archive and the web, the changed files of all 412 open PRs and of 1,946 recent closed PRs, and the introducing PR read in full. Three open PRs touch the same files without fixing it.

Searches4 searches

No issue, PR, review comment, list thread, JIRA entry or downstream report describes this bug, and no merged or open change fixes it. JIRA predates the feature, so empty JIRA results are expected; GitHub search is phrase-like and was rate-limited at times; private security reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-2612 queries from the prior-report tool plus 16 manual searches (BpVectorReorderer, BPReorderingMergePolicy, reorder blocks parent, hasBlocks reorder, ToParentBlockJoinQuery reorder, DiversifyingChildren reorder, ...); every hit for the two class names readonly related items: #14097 (introduced it), #13123, #13125, #13128 (2024 block work), #12622, #14117
Open and recent PRs2026-10-26changed-file scans of all 412 open PRs and of 1,946 closed PRs created since 2025-01-01, filtered on the reorderer files and SortingCodecReader; diffs of #15430, #16571 and #15285 grepped for parent or block handling#15430 (moves BpVectorReorderer to core), #16571 (skips reordering for partitioned merges) and #15285 (Separate Panama and Vector classes: vector-API plumbing in BpVectorReorderer and its test) touch the same files; none fixes it. #16879 and #16847 touch only SortingCodecReader
Threads read in full2026-10-26#14097 (33 texts) and its unmerged predecessor #13683, grepped for block, parent, child, nested and join; 31 other related issues and PRsno discussion of document blocks
Mailing lists, JIRA archive, downstream code, web2026-10-2620 tool queries over dev@, java-user@, issues@ and commits@ plus 12 direct archive searches; 4 JIRA queries; 2 tool downstream queries plus class-name searches in Elasticsearch, OpenSearch, the OpenSearch k-NN plugin and Solr; 6 web queriesno report; downstream items name the classes only for other topics

History

Introduced by #14097 (merged 2025-01-07), which added BpVectorReorderer and let the policy take it; it has broken blocks ever since. Main only: no release contains the class, and the 10.x branches do not have it. 9.10.0 had the same failure with the postings reorderer, fixed in 9.11.0.

Versions tested7 rows

Introduced by #14097, "HNSW BP reordering" (msokolov; approved by jpountz; merged 2025-01-07 as 02a09f6ab12 on main). It added BpVectorReorderer, IndexReorderer and AbstractBPReorderer and widened the BPReorderingMergePolicy constructor from BPIndexReorderer to IndexReorderer, inside the IndexWriter gate that #13128 (4a3f4cfe974, 2024-03-29) had made conditional on a parent field. Later commits to the file (#14117, a split heuristic; #16255, comment typos) add no block handling. CHANGES.txt lists #14097 under 11.0.0; no release tag contains the commit, and branch_10x, branch_10_6 and branch_10_5 have neither BpVectorReorderer nor IndexReorderer (read). The same failure class existed once before: on 9.10.0 the policy with the postings reorderer broke blocks the same way, and 9.11.0 fixed that with #13125 and #13128 (executed with a release control variant).

Versions and branches executed, with results
Version or commitVerdictResult
9.12.3, 10.1.0, 10.2.0, 10.5.2 not affectedno BpVectorReorderer: the repro does not compile; the policy with BPIndexReorderer keeps blocks
main 72d93de50c2 (parent of #14097) bisectthe repro does not compile; the policy keeps blocks
main 02a09f6ab12 (#14097) first affectedthe finder's test fails with assertions on and off; passes with the conservative fix
main b7c7fe064ce (#14117) affectedfails the same way
main ea57360912ed affectedthis doc's test fails 2 of 2 (gradle and harness, assertions on and off); the join variant 6 of 8 wrong
main a9779ef3c193 (2026-10-26) affectedthe finder's test still fails (main refresh)
main + fix A not affectedthe test, the closest suites and the whole misc module pass

Independent checks

Every check says real: a refute review and an "intended" review by OpenAI GPT-5.6, an independent reproducer, a devil's advocate and an adjudication, each with its own runs and controls. This doc re-ran a new 4-block test on main through gradle and the harness, and validated the fix on the whole misc module.

Checks and verdicts428 words

All checks ran on main ea57360912ed, with assertions on and off, and with the fixed seeds recorded in their result files.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateIts 64-block test fails 1 of 2 with assertions on and off ('child b13c0 ended up in the block of parent b0p'); the BPIndexReorderer control passes; its fix sketch passes 2/2 and 9 upstream tests.
Refute review (OpenAI GPT-5.6) real (high)Own 48-block test fails 1 of 3 with assertions on and off; the ordinary-merge control and the same policy without a parent field (reorder skipped) pass; the fix passes 3/3 and 9/9 component tests.
Independent reproducer realOwn test from the claim only: 2 of 4 fail; ToParentBlockJoinQuery wrong for 59 of 60 children (child-only vectors) and 56 of 60 (every document); both controls pass; 10.5.2 does not compile it.
'intended' reviewer (OpenAI GPT-5.6) real (high); intended case moderate, 'weak' in its notesOwn test fails 1 of 3 with assertions on and off and passes with the fix; a verifier variant (minPartitionSize 2) broke 64 of 64 blocks in every vector layout.
Devil's advocate realNo intended, misuse or unreachability argument survives; default settings break 46 of 48 documents (child-only vectors) and 48 of 48 (parent-only); 15 of 16 children joined to b0p; diversified kNN k=3 returns 1 hit.
Adjudication confirmed (high), silent wrong resultsRe-ran the independent reproduction's test with fresh seeds (2 of 4 fail each, assertions on and off) and the finder's test with another seed; the fix sketch passes.
History, prior reports, security main only since #14097; new; out of scope of SECURITY.mdBisect of #14097 and its parent, release controls, 82 recorded prior-report searches and checks with nothing found, and a security probe on main (assertions on and off) with the fix passing 7 of 7.
This doc re-run and fix validatedNew 4-block test fails 2 of 2 on main through gradle (assertions on and off) and the harness (4 seeds); the join variant shows 6 of 8 wrong joins and 1 kNN hit; the fix passes it, the closest suites and the whole misc module.
Fact checks of this doc bug holds; precision fixes appliedAn independent fact check re-ran the test (gradle, the harness and a new seed), the join variant and the fix (including the @Nightly policy tests: 95 tests, 0 failures), and found that with vectors on every document default settings break blocks only from 64 vectors.

Sources and provenance

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

Finding record

LFV-093, snapshot of 2026-11-06

Registry title
Merges through BPReorderingMergePolicy with BpVectorReorderer break document blocks even when a parent field is configured
Area
misc / BP merge-time reordering
Priority
Medium, rank 100: Every merge that BPReorderingMergePolicy with BpVectorReorderer reorders silently scrambles document blocks, so block joins return wrong parents, but it is unreleased (main only) and an expert setup with no known user.
Impact class
silent wrong results
Impact note
Applications that opt into the expert lucene/misc BPReorderingMergePolicy with a BpVectorReorderer on an index that has document blocks and IndexWriterConfig#setParentField (and no index sort): every merge the policy reorders silently scrambles the merged segment's blocks, whichever documents carry the vector (children only, parents only or all) and with default reorderer settings, and block-join queries then return the wrong parents and children; ordinary scale (16 blocks suffice); unreleased main (11.0.0) only.
Summary
You index documents in blocks (children followed by their parent) with IndexWriterConfig#setParentField, and use BPReorderingMergePolicy with a BpVectorReorderer over a float vector field. You expect merges to keep every block together with its parent last, because IndexWriter only lets a merge reorder documents when the parent field identifies the blocks, and setParentField promises that IndexWriter does not alter the order of a block. Instead BpVectorReorderer moves each document on its own and puts every document without a vector after all documents with one, so the merged segment's blocks are silently scrambled and ToParentBlockJoinQuery joins children to the wrong parent (59 of 60 children in one run), with no exception and with assertions on or off.
Introduced
02a09f6ab124 (#14097 'HNSW BP reordering', main) [read: git log --follow and git blame on BpVectorReorderer.java; exec: the repro fails at 02a09f6ab12 with -ea and -da, passes there with the conservative fix sketch shadowed, and does not compile at its parent 72d93de50c2, where BPReorderingMergePolicy accepts only the block-aware BPIndexReorderer and keeps blocks]; #14097 added BpVectorReorderer without block handling and widened the BPReorderingMergePolicy constructor from BPIndexReorderer to IndexReorderer, inside the IndexWriter reorder gate that #13128 (4a3f4cfe974, 2024-03-29) had made conditional on a parent field; not a regression of BpVectorReorderer, which has broken blocks since it was added (2025-01-07)
First affected version
unreleased (main only, 11.0.0-SNAPSHOT; no release tag contains 02a09f6ab12)
Affected versions
main from 02a09f6ab12 (2025-01-07) through ea57360912ed [exec: 02a09f6ab12, b7c7fe064ce (#14117) and ea57360912ed fail the same way, with -ea and -da at 02a09f6ab12 and on main; read: BpVectorReorderer.java is byte-identical at 7cecf795236 and ea57360912ed, and its only later commits, #14117 and #16255, add no block handling]; no release is affected: 9.12.3, 10.1.0, 10.2.0 and 10.5.2 have no BpVectorReorderer or IndexReorderer, so the repro does not compile, and their BPReorderingMergePolicy (BPIndexReorderer only) keeps blocks under the same merge [exec]; branch_10x, branch_10_6 and branch_10_5 have neither class [read]; CHANGES.txt lists GITHUB#14097 under 11.0.0, so 11.0.0 ships it unless it is fixed first [read]
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, all read-only: a scripted prior-report search with 12 GitHub issue/PR, 2 commit, 2 downstream-repo, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA-archive and 6 web queries; 16 manual gh issue/PR searches (3 of them over 4 downstream repos); 12 direct mailing-list archive queries; changed-file scans of all 412 open PRs and of the 1,946 PRs created 2025-01-01..2026-10-26 and closed; bodies and comments of 33 related issues and PRs (28 upstream, 5 downstream) grepped for block, parent, nested and child; the finder's earlier run (25 queries) and the intended-behaviour check's 20 list queries found the same); related prior items: #14097 (introduced it, no mention of blocks), #13125 and #13128 (the block-preservation work for BPIndexReorderer and OneMerge#reorder), #13123, #12622, #14117; open #15430 (moves BpVectorReorderer to core) and #16571 (skips reorder only for partitioned merges) touch the same files without a fix
Lucene commit tested
main ea57360912ed (commit of 2026-10-24; tested 2026-10-26) through gradle in a scratch worktree (assertions on and off, seed DEADBEEF) and through a standalone JUnit harness (seeds DEADBEEF, 5EED1234, CAFEBABE and 0FACE123, assertions on and off); the block-join variant with the join module (assertions on and off); the fix in the scratch worktree (the test, the closest suites, the whole misc module with assertions on and off, :lucene:misc:check -x test), removed afterwards; the verifiers' tests and the security probe with the patched class shadowed in; the 10.5.2 release jars (the test does not compile: no BpVectorReorderer). The fact check re-ran the test (gradle and harness, a new seed), the fix (adding the @Nightly policy tests), every-document layouts at default settings (4 to 32 blocks) and the offline tool. Earlier stages bisected the introducing commit 02a09f6ab12 and its parent, and the main refresh re-ran the finder's test on main a9779ef3c193 (2026-10-26): still fails.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5544-5577 (mergeMiddle: reorder only without an index sort and when every segment with blocks has a parent field, then SortingCodecReader.wrap(mergedView, docMap, null) with no block check), :3548-3566 (the same gate in addIndexesReaderMerge; not executed), :1495-1516 (addDocuments javadoc: 'merging will preserve a block'; the NOTE on offline re-sorting tools)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:578-589 (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')
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:311-325 (OneMerge#reorder javadoc, @lucene.experimental, no explicit block clause)
  • a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:578-593 (computeDocMap, never reads the parent field), :735-789 (valueMapToDocMap: 'sort docs with no value at the end')
  • a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:876-892 (builds a parents BitSet from the parent field and keeps blocks together, #13125)
  • a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:34-62 (class javadoc names only BPIndexReorderer; constructor takes an IndexReorderer), :46 (minNaturalMergeNumDocs = 1), :164-200 (natural, forced, forced-deletes and full-flush merges are wrapped)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1227-1278 (the only parent-field-aware order check runs inside the index-sort test)
  • a Lucene main checkout lucene/misc/src/test/org/apache/lucene/misc/index/TestBPReorderingMergePolicy.java:54 (picks BpVectorReorderer; the test never calls setParentField or addDocuments)
  • a Lucene main checkout lucene/CHANGES.txt:158 (GITHUB#14097 under 11.0.0), :2741-2742 (GITHUB#13125: BP supported on indexes with blocks when a parent field is configured)
  • a Lucene main checkout SECURITY.md:28 (downstream application handles untrusted input), :42 (configuration values are trusted as given), :50 (in-scope reports)
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5544-5577 (mergeMiddle reorder gate and SortingCodecReader.wrap)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:580-588 (setParentField javadoc)
  • Lucene main ea57360912ed: lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:133-145
  • Lucene main ea57360912ed: lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:208-211, 578-593, 735-789 (no 'parent' or 'block' in the file: grep)
  • Lucene main ea57360912ed: lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:376-382, 450-455, 878-882
Who is affected
  • Lucene main ea57360912ed: lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:46-47, 54-57, 102-105, 136-141, 163-203 (thresholds and their defaults; javadoc suggestion; forced merges; RAM skip; wrapped merge kinds)
  • Lucene main ea57360912ed: lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:208-211, 586-589, 699-724, 737-738 (no split below 2 x minPartitionSize; no field, no map; identity map when every document has a vector)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3548-3566 (the same gate in addIndexesReaderMerge; not executed)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1227-1278 (parent-aware check inside the index-sort test)
  • Lucene main ea57360912ed: lucene/join/src/test/org/apache/lucene/search/join/ParentBlockJoinKnnVectorQueryTestCase.java:482
  • Lucene main ea57360912ed: lucene/CHANGES.txt:158 (GITHUB#14097 under 11.0.0)
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:580-588 (585-587: 'optional ... with index sorting'); IndexWriter.java:1499-1500, 1506-1516; MergePolicy.java:311-324; lucene/MIGRATE.md:879-880
  • Lucene main ea57360912ed: lucene/test-framework/src/java/org/apache/lucene/tests/index/MockRandomMergePolicy.java:268-300
  • Lucene main ea57360912ed: lucene/CHANGES.txt:2742-2743; lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:51-54
  • Lucene main ea57360912ed: lucene/misc/src/test/org/apache/lucene/misc/index/TestBPReorderingMergePolicy.java:49-55; TestBpVectorReorderer.java:387; TestBPIndexReorderer.java:139-228
Suggested fix
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SortingCodecReader.java:453-460 (null map); IndexWriter.java:3548-3566, 5544-5577
  • Lucene main ea57360912ed: lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:376-382, 450-455; BPReorderingMergePolicy.java:34-39, 60
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SlowCompositeCodecReaderWrapper.java:78-94 (hasBlocks ORed over the inputs)
History
  • Lucene main ea57360912ed: lucene/CHANGES.txt:158

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.