All findings · Lucene finding · LFV-278 · merged-vector-iterator-ignores-sub-ord

A reordered addIndexes of SortingCodecReader inputs writes vectors onto the wrong documents

Re-sort segments with SortingCodecReader, add them in one addIndexes call to a writer whose merge policy reorders documents (such as BPReorderingMergePolicy), and the documents get other documents' vectors: 8 of 8 in the repro. Nothing throws, CheckIndex reports the index clean, and every 10.x release is affected.

Summary

Priority
Medium, rank 89Vectors silently land on the wrong document, CheckIndex does not catch it, and it is released since 10.0, but it needs a rare opt-in SortingCodecReader and reordering-merge mix, so it is the last released row before the unreleased rows.
Impact
data corruption
Since
10.0.0 (#13779, 2024)introduced 2024-09-28 · about 2 years
Affected
10.0.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
randomized differential testing of a merged reader view

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

Three opt-in pieces meet: SortingCodecReader, a re-sorted view that leaves each vector at its original position; one addIndexes call with several inputs; and a merge policy that reorders. To reorder, Lucene joins the inputs into a merged view, and that view counts vectors by position instead of asking each input where its vectors are.

  1. Build two small segments, ids 0-3 and ids 10-13, where each document's vector is {id, 100 - id}, so a vector names its owner. Re-sort each with SortingCodecReader by descending rank, so id 3 comes first.
  2. Add both in one addIndexes(sa, sb) call to a writer with no index sort whose merge policy reorders the merge: BPReorderingMergePolicy, or a custom OneMerge#reorder that returns the identity and moves nothing.
  3. You expect every document to keep its vector: doc 0 (id 3) should hold [3.0, 97.0], as it does when the merge policy does not reorder.
  4. Instead Lucene joins the inputs into one merged view that numbers vectors 0, 1, 2, 3 in the new order but looks number k up at the input's original position k. Doc 0 (id 3) gets id 0's vector [0.0, 100.0]; all 8 documents get another document's float and byte vector.
  5. No error with assertions on or off, CheckIndex is clean, and kNN search ranks by the wrong embeddings until the segment is rebuilt. If id 0 was deleted before the call, its vector still lands on live id 3.

Who is affected

Applications that pass two or more inputs, at least one a SortingCodecReader, to one addIndexes(CodecReader...) call on a writer with no index sort whose merge policy reorders addIndexes merges (BPReorderingMergePolicy or a custom OneMerge#reorder). All opt-in; no scale needed. Two safe, tested usage patterns and regular merges over the built-in segment readers are not affected.

Mechanism, terms and numbers736 words

SortingCodecReader's javadoc suggests re-sorting an index by wrapping its readers and passing them to IndexWriter#addIndexes(CodecReader...). When two or more inputs go into one such call (at least one a SortingCodecReader, built from a Sort or from a Sorter.DocMap such as BPIndexReorderer.reorder's output), the target writer has no index sort (and no input has document blocks without a parent field), and the merge policy's OneMerge#reorder returns a DocMap (misc BPReorderingMergePolicy computes one for every addIndexes merge, unless its reorderer runs out of RAM; an identity DocMap is enough), IndexWriter reorders through a merged view, SlowCompositeCodecReaderWrapper. Its vector iterator numbers ordinals by position while its vectorValue(ord) reads in each input's own ordinal space, and a SortingCodecReader keeps its vectors at their original ordinals. So within each re-sorted input the k-th document in sorted order is written with the vector at original ordinal k: in the repro all 8 documents get another document's float and byte vector, and a deleted document's vector lands on a live one. Nothing throws, CheckIndex reports the index clean, and kNN search ranks by other documents' embeddings until the segment is rebuilt. All 12 10.x releases from 10.0.0 to 10.5.2, 10.6.0 (by reading) and main are affected; it was introduced by #13779, and a two-line change (one line changed, one removed) fixes it.

Terms

  • Ordinal: a vector's index inside a field's vector values. An iterator's index() gives the ordinal of its current document, and vectorValue(ord) reads that vector.
  • SortingCodecReader: a merge-only view of a segment in a new document order. It does not move the vectors: it visits documents in the new order, and its index() returns each document's original ordinal (its javadoc: "index() may skip around, not increasing monotonically").
  • Reordered addIndexes: when the target writer has no index sort, addIndexes joins all inputs of one merge into a merged view (SlowCompositeCodecReaderWrapper) and asks OneMerge#reorder for a new document order (unless an input has document blocks but no parent field). BPReorderingMergePolicy computes one for every addIndexes merge with a live document, unless its reorderer runs out of RAM.

The bug

  1. The merged view's iterator numbers ordinals 0, 1, 2, ... as it walks the documents: ++ord per document (SlowCompositeCodecReaderWrapper.java:342 at ea57360912ed) and ord = current.ordStart - 1 on moving to the next input (:352).
  2. Its vectorValue(ord) looks the ordinal up in the input's own ordinal space, sub.vectorValue(ord - sub.ordStart) (:912-920 float, :1011-1019 float16, :1060-1069 byte), with the comment that this random access exists "to support callers like SortingCodecReader that rely on it". That caller is the SortingCodecReader wrapped around the view (item 4); the comment does not speak to sorted inputs of the view, and nothing excludes them.
  3. The two agree only if each input's index() equals its iteration position. On-disk segments satisfy that; a SortingCodecReader does not: index() returns docToOrd[doc] and vectorValue(ord) reads the wrapped reader at that ordinal (SortingCodecReader.java:265-290, :323-326).
  4. IndexWriter.addIndexesReaderMerge builds SortingCodecReader.wrap(SlowCompositeCodecReaderWrapper.wrap(readers), docMap, null) whenever the writer has no index sort, no input has document blocks without a parent field, and the reorder returns a DocMap (IndexWriter.java:3548-3565). The outer reader records the merged iterator's ordinal for each document, and the codec's merge writes the vector read at that ordinal (current.values.vectorValue(current.index()), KnnVectorsWriter.java:484).
  5. Input A re-sorted descending is visited as ids 3, 2, 1, 0 and gets merged ordinals 0, 1, 2, 3. Ordinal 0 is A's original ordinal 0, id 0's vector. So id 3 is written with id 0's vector, id 2 with id 1's, and so on; input B the same. The reorder itself does not matter: an identity DocMap takes the same path.
  6. The codec merge code written in the same commit reads each input's own ordinal, current.values.vectorValue(current.index()) (KnnVectorsWriter.java:484, :643). Only the merged view assumes positional ordinals.

The numbers

target merge policydoc 0 (id 3) getsdocuments with another document's vector
LogDocMergePolicy (no reorder), control[3.0, 97.0]0 of 8
identity OneMerge#reorder[0.0, 100.0] (id 0's)8 of 8, float and byte
identity reorder, id 0 deleted before the call[0.0, 100.0] (deleted id 0's)7 of 7 live documents
BPReorderingMergePolicy(LogDocMergePolicy, BPIndexReorderer)[0.0, 100.0]fails on doc 0 (float test)
either reorder, with the fix[3.0, 97.0]0

Nothing throws with assertions on or off, and CheckIndex reports the new index clean: every stored vector is valid, just on the wrong document.

Exposure and likelihood411 words
  • Who is exposed. An addIndexes(CodecReader...) merge with two or more inputs, at least one a SortingCodecReader (from SortingCodecReader.wrap(reader, Sort), or from a Sorter.DocMap, for example misc BPIndexReorderer.reorder(...), which returns one), into a writer with no index sort whose merge policy's OneMerge#reorder returns a DocMap for the addIndexes merge (misc BPReorderingMergePolicy, or any custom reorder, even the identity). The default findMerges(CodecReader...) puts all inputs in one merge. A tool that re-sorts or imports segments into a writer configured with BPReorderingMergePolicy would hit it; no such tool is known.
  • Executed triggers. Inputs re-sorted by a Sort; two BPIndexReorderer.reorder outputs with no Sort at all; one SortingCodecReader next to a plain input (only the sorted input's vectors move). Float and byte vectors were executed; float16 vectors (main only) use the same iterator and mismatched in the finder's differential harness, but no JUnit covers them.
  • Not affected. Two safe, tested in-tree usage patterns (executed; neither is documented as a requirement): a target writer with the same index sort as the inputs (no reorder runs) and one BPIndexReorderer output per addIndexes call (no merged view is built). Regular segment merges over the built-in segment readers, by code reading: their ordinals are positional, and wrapForMerge may not reorder doc IDs; a custom codec or wrapForMerge wrapper with non-positional vector ordinals was not ruled out.
  • Opt-in. SortingCodecReader and OneMerge#reorder are @lucene.experimental; BPReorderingMergePolicy is a misc-module class. Nothing else is non-default: the tests pin the default codec and use the test framework's randomized directories.
  • Scale and frequency. 8 documents in 2 segments; deterministic (over 10 iterations all 20 bug-test runs failed and all 10 control runs passed). Once on that path, every document with a vector whose input's index() differs from its position in that input's iteration order gets a wrong vector; documents whose ordinal equals their position keep theirs.
  • Silent and lasting. No exception, CheckIndex clean; the wrong vectors are written to the new segment, so kNN search and any code that reads vectors back see them until the segment is rebuilt from source data. A code fix stops new damage only.
  • Intact. Ids, stored fields, postings and doc values; vectors stayed inside their own input in every executed case.
  • Downstream (read, not tested). No BPReorderingMergePolicy in Elasticsearch's code (code search); Solr makes it configurable (apache/solr#2641), but whether any Solr path also feeds SortingCodecReader inputs to addIndexes was not checked; the OpenSearch, Solr and OpenSearch k-NN code searches hit a rate limit.

Minimal repro

The test shows that addIndexes of two SortingCodecReader-wrapped segments through a merge policy whose addIndexes merge returns an identity OneMerge#reorder writes vectors onto the wrong documents.

TestReorderedAddIndexesOfSortedReaders.java (JUnit test)102 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.index;

import static org.apache.lucene.search.DocIdSetIterator.NO_MORE_DOCS;

import java.util.concurrent.Executor;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.KnnFloatVectorField;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestReorderedAddIndexesOfSortedReaders extends LuceneTestCase {

  private static MergePolicy identityReorder() {
    return new FilterMergePolicy(new LogDocMergePolicy()) {
      @Override
      public MergeSpecification findMerges(CodecReader... readers) {
        MergeSpecification spec = new MergeSpecification();
        spec.add(
            new OneMerge(readers) {
              @Override
              public Sorter.DocMap reorder(CodecReader reader, Directory dir, Executor executor) {
                int maxDoc = reader.maxDoc();
                return new Sorter.DocMap() {
                  @Override
                  public int oldToNew(int docID) {
                    return docID;
                  }

                  @Override
                  public int newToOld(int docID) {
                    return docID;
                  }

                  @Override
                  public int size() {
                    return maxDoc;
                  }
                };
              }
            });
        return spec;
      }
    };
  }

  public void testIdentityReorderKeepsVectors() throws Exception {
    try (Directory src = newDirectory();
        Directory dst = newDirectory()) {
      try (IndexWriter w =
          new IndexWriter(src, new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE))) {
        for (int id = 0; id < 4; id++) {
          Document doc = new Document();
          doc.add(new NumericDocValuesField("id", id));
          doc.add(new KnnFloatVectorField("v", new float[] {id}));
          w.addDocument(doc);
          if (id == 1) {
            w.flush();
          }
        }
      }
      Sort byIdDesc = new Sort(new SortField("id", SortField.Type.LONG, true));
      try (DirectoryReader r = DirectoryReader.open(src);
          IndexWriter w =
              new IndexWriter(dst, new IndexWriterConfig().setMergePolicy(identityReorder()))) {
        w.addIndexes(
            SortingCodecReader.wrap((CodecReader) r.leaves().get(0).reader(), byIdDesc),
            SortingCodecReader.wrap((CodecReader) r.leaves().get(1).reader(), byIdDesc));
      }
      try (DirectoryReader r = DirectoryReader.open(dst)) {
        LeafReader leaf = getOnlyLeafReader(r);
        NumericDocValues ids = leaf.getNumericDocValues("id");
        FloatVectorValues vectors = leaf.getFloatVectorValues("v");
        KnnVectorValues.DocIndexIterator it = vectors.iterator();
        for (int doc = it.nextDoc(); doc != NO_MORE_DOCS; doc = it.nextDoc()) {
          assertTrue(ids.advanceExact(doc));
          float[] vector = vectors.vectorValue(it.index());
          // expected: the doc with id N has vector {N}; actual: it has another document's vector
          assertEquals("vector of id " + ids.longValue(), ids.longValue(), vector[0], 0f);
        }
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-278/, 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: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.index.TestReorderedAddIndexesOfSortedReaders
Expected vs actual
Expected every document to keep its own vector ({id}); actual: the document with id 1 has the vector of id 0 (vector of id 1 expected:<1.0> but was:<0.0>), with -ea and -da.

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

More files, environment, full output37 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: vector of id 1 expected:<1.0> but was:<0.0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:577)
       org.apache.lucene.index.TestReorderedAddIndexesOfSortedReaders.testIdentityReorderKeepsVectors(TestReorderedAddIndexesOfSortedReaders.java:97)

Why it looks unintended

  • index() must return "the value index (aka "ordinal" or "ord") corresponding to the current doc"; the merged view's index() names another document's vector.
  • The merged view's random access is there "to support callers like SortingCodecReader", whose index() "may skip around"; nothing rejects sorted inputs.
  • An identity reorder moves no document, yet the vectors move; 9.9.0-9.12.3 keep them in place.
The full argument369 words
  • The contract. KnnVectorValues.DocIndexIterator.index(): "return the value index (aka "ordinal" or "ord") corresponding to the current doc" (KnnVectorValues.java:140). The merged iterator's index() names another document's vector.
  • The merged view is built to serve SortingCodecReader. "We need to implement fully random-access API here in order to support callers like SortingCodecReader that rely on it" (SlowCompositeCodecReaderWrapper.java:914-915, also :1014-1015, :1063-1064). The caller meant is the SortingCodecReader wrapped around the view, as IndexWriter builds it; the comment does not speak to sorted inputs of the view, but that outer reader only works if the view's ordinals match its vectors, and nothing keeps sorted readers out.
  • SortingCodecReader documents both its ordinals and this use. "index() may skip around, not increasing monotonically as iteration proceeds" (SortingCodecReader.java:265-266), and "This can be used to re-sort and index after it's been created by wrapping all readers of the index with this reader and adding it to a fresh IndexWriter via IndexWriter#addIndexes(CodecReader...)" (:48-50).
  • OneMerge#reorder only renumbers doc IDs. "Extend this method if you wish to renumber doc IDs. This method will be called when index sorting is disabled on a merged view of the OneMerge" (MergePolicy.java:312-313). An identity DocMap renumbers nothing and still moves the vectors.
  • No precondition, no rejection. Nothing documents that addIndexes inputs need positional vector ordinals; validateMergeReader checks an input's sort only when the writer has an index sort (IndexWriter.java:3228).
  • History. #13779 (6053e1e3137, 2024) added both halves in one commit; before it the merged view returned the current input's vector at the current document and 9.x SortingCodecReader copied vectors into the new order. None of its 37 inline review comments is on SlowCompositeCodecReaderWrapper or SortingCodecReader, and none says that an input's ordinals must be positional. The codec merge path of the same commit reads each input's index() (KnnVectorsWriter.java:484, :643).
  • Why tests missed it. TestSortingCodecReader always gives the target the same index sort (no reorder); TestBPReorderingMergePolicy.testReorderOnAddIndexes adds plain inputs and checks stored fields, never vectors; MockRandomMergePolicy does not reorder addIndexes merges.
  • Precedent. Upstream fixed other defects in this merged view as bugs (#14092) and an ordinal-tracking defect of the same kind in KnnVectorsWriter.MergedByteVectorValues (#14992 / #15553).
The devil's-advocate check194 words

The strongest "works as designed" case (the intended-behaviour check, the intended-behaviour devil's advocate, and the cross-model lenses): the merged view is package-private and "primarily targeted at merging, not searching"; SortingCodecReader and OneMerge#reorder are @lucene.experimental; the two in-tree usage patterns are safe (a target with the same index sort, one reordered reader per addIndexes call; executed); the trigger sorts the data twice; and ++ord is correct for every on-disk reader.

Why it fails: the failure happens during a merge, the use the view exists for, and the view names SortingCodecReader as a caller to support (as its wrapper). Experimental status allows API changes, not silently wrong data. Neither safe pattern is documented as required; addIndexes is varargs, the default policy puts all inputs in one merge, BPReorderingMergePolicy deliberately reorders addIndexes merges, and nothing rejects or documents against the combination. An identity reorder sorts nothing and still corrupts. What remains on the other side is rarity: an unusual stack of opt-in APIs (and BPReorderingMergePolicy skips the reorder when its reorderer runs out of RAM) and no report since 10.0.0. The likely reply is "real, niche, fix it", not "by design"; no source supports by design.

Suggested fix

Recommended Use each input's own ordinal in the merged iterator (validated)

In MergedDocIterator.nextDoc, take the input's own ordinal, ord = current.ordStart + currentIterator.index(), instead of ++ord, and drop the reset on moving to the next input. With it every repro passes, and so do the default lucene/core and lucene/misc test tasks (with their usual skips), assertions on and off.

if (next != NO_MORE_DOCS) {
  // Use the sub's own ordinal: it need not follow the doc order (a SortingCodecReader
  // sub reports the ordinals of the reader it wraps), and vectorValue(ord) reads there.
  ord = current.ordStart + currentIterator.index();   // was: ++ord
  assert ord >= current.ordStart && ord < current.ordStart + current.sub.size();
  return doc = current.docStart + next;
}
...
current = it.next();
currentIterator = currentIterator();
// removed: ord = current.ordStart - 1;
All options and trade-offs3 options · 605 words

One option was built and validated; the others are listed with their trade-offs.

Use each input's own ordinal in the merged iterator (validated) (recommended)

SlowCompositeCodecReaderWrapper.MergedDocIterator.nextDoc: ord = current.ordStart + currentIterator.index() instead of ++ord, and remove ord = current.ordStart - 1 (1 changed line, 1 removed line, plus a comment and an assertion). The merged ordinal then names the same vector that the merged vectorValue(ord) reads.

It stays in range: Sorter.DocMap.size() "must be equal to the number of documents" of the reader it sorts, so a SortingCodecReader input reports every ordinal of the reader it wraps and ordStart + index() stays inside the input's [ordStart, ordStart + size()) slot. The added assertion checks that; it held in every run with assertions on. A DocMap that drops documents is outside that contract (flagged by the adjudication, untested); the assertion would catch it.

if (next != NO_MORE_DOCS) {
  // Use the sub's own ordinal: it need not follow the doc order (a SortingCodecReader
  // sub reports the ordinals of the reader it wraps), and vectorValue(ord) reads there.
  ord = current.ordStart + currentIterator.index();   // was: ++ord
  assert ord >= current.ordStart && ord < current.ordStart + current.sub.size();
  return doc = current.docStart + next;
}
...
current = it.next();
currentIterator = currentIterator();
// removed: ord = current.ordStart - 1;

Trade-offs

  • One index() call per vector in a reordered merge, next to the vector read itself; no benchmark was run.
  • Plain inputs and regular merges over the built-in segment readers get the same ordinals as before (index() equals the position there).
  • Prevents new damage only: indexes already written through this path keep the wrong vectors and must be rebuilt.
  • Leaves a separate question open (read, not run): with misc BpVectorReorderer as the reorderer, each reorder thread calls vectors.copy() on the merged view (BpVectorReorderer.java:110, :619-624), which copies every input, and Sorting*VectorValues.copy() throws UnsupportedOperationException (SortingCodecReader.java:345-348); its sparse-field path also counts ordinals by position (:735-770). By code reading that combination ends in the exception, with or without this fix, once the merged view is large enough to be split (:208-217); below that the reorder is the identity and this fix applies.

What the checks validated

  • Scratch worktree at ea57360912ed (removed afterwards): this doc's test and the finder's misc test fail 2 of 3 each on stock and pass 3 of 3 each with the fix, assertions on and off.
  • With the fix, the default lucene/core test task (8,868 tests, 322-325 skipped) and lucene/misc test task (318 tests, 9 skipped), with assertions on and off, and check -x test for both modules pass (0 failures).
  • The same patched class as a shadow class: the independent reproducer's 6 tests, the security probe's 3 and the devil's advocate's 3 reach probes pass. The finder's variant without the assertion also passed four upstream BP and wrapper test classes and fixed the bug at the introducing commit.

Make SortingCodecReader expose positional ordinals (not run)

Copy or remap vectors into the new order, as 9.x did.

Trade-offs

Turns a memory-light merge-only reader into one that buffers or remaps every vector, and leaves the merged view still assuming positional inputs for any future input with non-positional ordinals (for example the on-disk iterator of open PR #15430, read only). Not recommended.

Refuse or skip the reorder for such inputs (not run)

Throw in addIndexes, or skip the reorder, when an input's vector ordinals are not positional.

Trade-offs

Turns silent corruption into an error or a lost reorder, but there is no cheap general way to detect non-positional ordinals, and it blocks a combination the javadocs invite. A stop-gap at most.

Downstream note

Out of scope of SECURITY.md: only the application's own configuration reaches this code, and SECURITY.md trusts configuration. Downstream, the effect crosses deletion and access boundaries and lasts: a filtered kNN search returned public documents at score 1.0 computed from a deleted and a private document's embeddings. Filters held; no ids or stored fields leaked.

How it was found

Randomized differential testing: the finder, an AI tool, compared the merged reader view that addIndexes uses for reordering against each input's own data and saw vector mismatches only when SortingCodecReader inputs were present. A small Z3 model then stated the hidden assumption, and a public-API JUnit confirmed it on the stock jars.

The method in full208 words
  • Unit and method. A hunt unit (codec-reader wrappers), 2026-10-26. The finder's randomized differential harness compared SlowCompositeCodecReaderWrapper's merged view, and addIndexes through it with an identity reorder, against the per-input data: 3 runs x 150 iterations with SortingCodecReader inputs allowed. It reported float, byte and float16 vector mismatches (for example 7 float, 41 byte and 3 float16 in one run) only in the runs with SortingCodecReader inputs.
  • The model. A small Z3 model of the merged iterator stated the hidden assumption, for inputs of 2 to 4 vectors: with monotone input ordinals no document can be given another document's vector (unsat); with an arbitrary permutation, as a SortingCodecReader allows, one can (sat: a witness exists). The first signal was the random differential, so this counts as a non-formal find.
  • Then. A public-API JUnit on the stock jars; a cross-model review by a reviewer from another model family (OpenAI GPT-5.6, read-only, refute and intended lenses); an independent reproducer with its own tests and a 9.x port; a GPT-5.6 root-cause adversary; an intended-behaviour devil's advocate with reach probes; an adjudication (confirmed, high); a history sweep with commit builds; a prior-report search; a security assessment and probe; a registry re-run with fresh seeds; and this doc's own test and fix validation.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26: GitHub issues, PRs and commits, all 413 open PRs' changed files, the lucene.apache.org lists, the JIRA archive, downstream repositories and the web; 23 hits read). Not fixed upstream. Closest: open PR #16879, which reuses HNSW graphs through the same merge path but does not touch this iterator.

Searches6 searches

Nobody has reported or discussed this bug, and no open PR changes MergedDocIterator. Closest items, none the same bug:

  • #16879 (open, 0 comments): HNSW graph reuse through the same SortingCodecReader-over-merged-view path. It does not touch the iterator; if both land, a reused graph and the flat vectors could disagree for SortingCodecReader inputs (not analysed).
  • #13779: the PR that introduced the code.
  • #14092: an earlier, different defect in the same merged view, fixed as a bug.
  • #15430 (open, stale): an on-disk vector iterator with non-positional index(); if it lands, plain addIndexes(CodecReader...) could hit the same assumption without any reorder (read, not executed).
  • #14992 / #15553: an ordinal-tracking defect of the same kind in another class, fixed.

Limits: GitHub search is keyword-based and some queries needed retries after rate limiting; the downstream code searches for BPReorderingMergePolicy in OpenSearch, Solr and the OpenSearch k-NN plugin are unchecked; private security reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-26a scripted prior-report search twice (8 and 16 issue/PR queries) and own read-only searches: SortingCodecReader, BPReorderingMergePolicy, SlowCompositeCodecReaderWrapper, MergedDocIterator, addIndexes vectors wrong, vector ordinal mismatch merge, reorder addIndexes vectorsno report; closest #16879, #13779, #14092, #15430, #14992 / #15553
Open PRs by changed file2026-10-26the file lists of all 413 open apache/lucene PRs, filtered on the defect files and their neighboursnone changes MergedDocIterator; #16847, #16879, #13342 touch the file for other reasons
Commits and upstream history2026-10-26commit search (2 and 3 queries) and the history of the three defect filesthe defect line is unchanged since 6053e1e3137 (#13779)
Mailing lists (lists.apache.org)2026-10-2612 and 20 queries over dev@, java-user@, issues@ and commits@, plus direct searchesonly PR notification mails; no report
JIRA archive2026-10-264 queries in each tool runonly items from before 10.0, before the code existed
Downstream (Elasticsearch, OpenSearch, OpenSearch k-NN, Solr) and the web2026-10-26issue searches, code searches for BPReorderingMergePolicy, 4 web queriesno report; Solr makes BPReorderingMergePolicy configurable; some code searches rate-limited

History

Introduced in 10.0.0 by #13779 (6053e1e3137, merged 2024-09-28), which added both halves: positional counting in the merged view and original-order ordinals in SortingCodecReader. All 12 10.x releases and main are affected; 9.9.0-9.12.3 are not. The commit and its parent were built and compared.

Versions tested6 rows

Introduced by #13779 "First-class random access API for KnnVectorValues" (@msokolov, for issue #13778, merged 2024-09-28 as 6053e1e3137, milestone 10.0.0). git blame gives it every ordinal line of MergedDocIterator and SortingValuesIterator; branch_10_0 was cut after it, so 10.0.0 has the commit itself. The addIndexes reorder path that reaches the code is older: #12622 (f7cab164501, @jpountz, 2023-11-23, first released in 9.9.0).

Executed: javac builds of 6053e1e3137 fail the finder's test (assertions on and off) and a version-portable probe (assertions on); its parent 7b4b0238d70 passes the probe (on and off); the fix applied at the commit passes both. Release branches after 10.5.2 were not run; by source inspection on 2026-10-26 the heads of main, branch_10x, branch_10_6 and branch_10_5 all still have ++ord and the ordStart - 1 reset.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedthis doc's test fails 2 of 3 through gradle and the runner, assertions on and off; the finder's, the reproducer's and the security probe's tests fail too
10.0.0 - 10.5.2 (12 releases) affectedthe finder's test and the portable probe fail 2 of 3 on each; this doc's test fails on 10.5.2 (-ea, -da) and 10.0.0
6053e1e3137 (#13779) vs its parent 7b4b0238d70 bisectthe commit fails the finder's test (-ea and -da) and the probe (-ea); the parent passes the probe (-ea and -da); the commit plus the fix passes both
9.9.0 - 9.12.3 (10 releases) not affectedthe portable probe passes; its precondition shows the reorder ran over the merged view of both inputs
9.8.0 and older not applicableno addIndexes reorder hook, BPReorderingMergePolicy or merged view (the probe does not compile)
fix (merged-iterator-uses-sub-index.diff) not affectedevery repro passes, and so do the default lucene/core test task (8,868 tests, 322-325 skipped) and lucene/misc test task (318 tests, 9 skipped), with assertions on and off, and check -x test for both modules

Independent checks

The finder, an independent reproducer, an intended-behaviour devil's advocate and an adjudication say real (adjudication: confirmed, high). A read-only reviewer from another model family agreed with the root cause in all lenses; its "not real" and "uncertain" verdicts were procedural. A security assessment and the strict re-evaluation of 2026-10-27 rate it out of scope of SECURITY.md; a fresh-seed re-run reproduced it.

Checks and verdicts278 words

Every check ran on main ea57360912ed unless noted.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidatePublic-API misc JUnit fails 2 of 3 on main (-ea, -da), 10.5.2 and 10.0.0; passes with the fix, as do four upstream BP and wrapper test classes.
Cross-model review (OpenAI GPT-5.6, read-only) refute lens: not real (low, procedural); intended lens: real (medium)Could not run Java; agreed with the root cause, rated the inputs in contract and the works-as-designed argument weak. A separate run re-ran the repro each time (2 of 3 fail).
Independent reproducer realOwn 6-test JUnit fails 3 of 6 on main (-ea, -da), 10.5.2 and 10.0.0 with 3 controls passing; own misc test with BPReorderingMergePolicy fails; a 9.x port passes on 9.12.3; own fix passes.
Root-cause adversary (OpenAI GPT-5.6, read-only) uncertain (medium), proceduralAgreed with the root cause; inputs in contract; works-as-designed case weak.
Intended-behaviour devil's advocate realBuilt the strongest by-design case and showed why it fails; the two in-tree usage patterns are safe; two BPIndexReorderer outputs with no Sort fail on main and 10.5.2.
Adjudication confirmed (high), data corruptionRe-ran the independent reproduction's tests, the independent reproduction's misc test, the finder's repro and the independent reproduction's fix; flagged the DocMap-that-drops-documents edge for the patch.
Security assessment out of scope of SECURITY.mdProbe: a deleted and a private document's vectors on public documents, filtered kNN at score 1.0, CheckIndex clean; passes with the fix.
Registry re-run (fresh seeds) reproducedFinder's test and the independent reproduction's test fail with -ea and -da, the security probe with -da; all pass with the fix.

Sources and provenance

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

Finding record

LFV-278, snapshot of 2026-11-06

Registry title
SlowCompositeCodecReaderWrapper's merged vector iterator numbers ordinals by position and ignores each input's own index(), so addIndexes of two or more SortingCodecReader inputs through a reordering merge policy writes vectors onto the wrong documents
Area
index / addIndexes reorder path: SlowCompositeCodecReaderWrapper merged vector values (MergedDocIterator) over SortingCodecReader inputs
Priority
Medium, rank 89: Vectors silently land on the wrong document, CheckIndex does not catch it, and it is released since 10.0, but it needs a rare opt-in SortingCodecReader and reordering-merge mix, so it is the last released row before the unreleased rows.
Impact class
data corruption
Impact note
Applications on 10.0.0 through 10.6.0 (10.6.0 by reading) or main that call IndexWriter.addIndexes(CodecReader...) with two or more SortingCodecReader inputs (re-sorted by a Sort or by a BPIndexReorderer DocMap) into a writer with no index sort whose merge policy reorders addIndexes merges (misc BPReorderingMergePolicy, or any custom OneMerge#reorder, even an identity one) silently get float and byte vectors (and, on main, float16 vectors) persisted on other documents of the same input, so kNN search on the new index ranks documents by other documents' embeddings until the segment is rebuilt; nothing throws with -ea or -da and CheckIndex reports the index clean; every piece is opt-in and experimental, no scale is needed, and regular segment merges (by code reading) and the two documented recipes (a target with the same index sort, or one reordered reader per addIndexes call; executed) are not affected.
Summary
You re-sort two or more segments with SortingCodecReader and pass them to one IndexWriter.addIndexes(CodecReader...) call on a writer that has no index sort but uses a merge policy that reorders addIndexes merges, such as BPReorderingMergePolicy (an identity OneMerge#reorder is enough), and you expect every document to keep the vector it was indexed with, as it does without the reorder. Instead the merged view that IndexWriter builds for the reorder (SlowCompositeCodecReaderWrapper) numbers vectors by iteration position, while each SortingCodecReader input reports and reads vectors by its original ordinal, so in each input the k-th document in sorted order is written with the vector at original ordinal k. Nothing throws, with or without assertions, CheckIndex reports the new index clean, and kNN search then scores documents by other documents' vectors, in an executed probe including the vector of a document deleted before the call.
Introduced
6053e1e31378378f6d310a05ea6d7dcdfc45f48b (apache/lucene#13779 'First-class random access API for KnnVectorValues', msokolov, for issue #13778) [exec: javac source builds of this commit fail the finder repro and the version-portable probe with -ea and -da, its parent 7b4b0238d70 passes the probe, and the one-line fix applied at the commit passes both; read: git log -L over MergedDocIterator lists only f7cab164501 and this commit, and git blame attributes '++ord', 'ord = current.ordStart - 1', the merged vectorValue(ord) bodies and SortingCodecReader.SortingValuesIterator (index() returns docToOrd, javadoc 'may skip around') to it]. The one commit added both halves: positional ordinal counting in the merged view and non-positional ordinals in SortingCodecReader; before it the merged view returned the current input's vector at the current document and SortingCodecReader copied vectors into new-document order. The addIndexes reorder path that reaches the code is older: f7cab164501 (#12622, jpountz, 2023-11-23, first released in 9.9.0) (2024-09-28)
First affected version
10.0.0 [exec] (tagged 2024-10-14; branch_10_0 was cut from main after the commit, merge-base 22ac47c07ad, so 10.0.0 has the commit itself, not a backport)
Affected versions
all 12 10.x releases before 10.6.0 (10.0.0, 10.1.0, 10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2) and main ea57360912ed [exec: the finder repro and the portable probe fail 3 run / 2 failed on each, 10.5.2 and main also with -da; the independent reproduction's test fails on main, 10.5.2 and 10.0.0]; not affected: the ten releases 9.9.0 to 9.12.3 and the parent commit 7b4b0238d70 [exec: the portable probe passes and its non-vacuity check shows the reorder ran over the merged view of both inputs; the independent reproduction's 9.x port passes on 9.12.3]; not applicable: 9.8.0 and older, which have no reorder hook, BPReorderingMergePolicy or SlowCompositeCodecReaderWrapper [exec: the probe does not compile on 9.8.0]; float16 vectors exist only on main (#16383) and mismatched there in the finder's differential sweep [exec]; not fixed on main; release branches after 10.5.2 were not executed; 10.6.0 (released 2026-10-29) is affected by reading (the branch_10_6 head read on 2026-10-26 and the 10.6.0 revision 4ae946e5a9b keep ++ord and the ordStart - 1 reset)
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal; also reached by Z3
Upstream and prior reports
none found (2026-10-26, the prior-report record plus the finder's run: the scripted prior-report search twice (run 1: 8 GitHub, 2 commit, 12 list and 4 JIRA queries, 3920 hits, 1 high and 11 medium overlap; run 2: 45 queries, 16 GitHub, 3 commit, 2 downstream, 20 list over dev@, java-user@, issues@ and commits@, 4 JIRA, 322 hits, 0 high and 8 medium overlap), own read-only gh searches in apache/lucene and in downstream repositories (Elasticsearch, OpenSearch, the OpenSearch k-NN plugin, Solr), the changed files of all 413 open apache/lucene PRs, the upstream commit history of the three defect files, direct lists.apache.org searches and 4 web queries; 23 hits read, none the same bug); not fixed upstream (main ea57360912ed, the upstream HEAD at search time, still has '++ord' in MergedDocIterator.nextDoc, and no open PR changes it); closest items, not reports: #16879 (open, 0 comments; HNSW graph reuse through the same SortingCodecReader-over-merged-view path, does not touch MergedDocIterator), #13779 (the introducing PR), #14092 (an earlier, different defect in the same merged vector view, fixed as a bug), #15430 (open, stale since 2025-12; adds an on-disk vector iterator with non-positional index(), which could widen the trigger if it lands; read, not executed) and #14992 / #15553 (an ordinal-tracking defect of the same class in KnnVectorsWriter.MergedByteVectorValues, fixed)
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 3 with assertions on and off; with the fix it passes, and so do the default lucene/core test task (8,868 tests, 322-325 skipped) and lucene/misc test task (318 tests, 9 skipped), with assertions on and off, and check -x test for both modules; the worktree was removed afterwards) and through the standalone JUnit runner on main (-ea, -da, 10 iterations) and on the Maven Central jars of 10.5.2 (-ea, -da) and 10.0.0; the finder's test on every 10.x release and a version-portable probe on every release 9.9.0-10.5.2 (fail from 10.0.0) and on javac builds of the introducing commit 6053e1e3137 and its parent 7b4b0238d70.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/SlowCompositeCodecReaderWrapper.java:45-56 (at ea57360912ed) (class javadoc 'targeted at merging, not searching'; wrap() returns a single reader unchanged), :331-354 (MergedDocIterator: index() returns ord; nextDoc '++ord' at :342, 'ord = current.ordStart - 1' on a sub switch at :352), :826-840 (ordStart = running sum of earlier subs' vector counts), :912-920, :1011-1019, :1060-1069 (float, float16 and byte vectorValue(ord) -> sub.vectorValue(ord - ordStart), with the comment 'to support callers like SortingCodecReader that rely on it')
  • lucene/core/src/java/org/apache/lucene/index/SortingCodecReader.java:47-54 (at ea57360912ed) (class javadoc: re-sort by wrapping all readers and IndexWriter#addIndexes(CodecReader...); @lucene.experimental), :265-267 (SortingValuesIterator javadoc 'index() may skip around, not increasing monotonically'), :287-290 (index() returns docToOrd[doc] at :289), :323-326 (vectorValue: 'ords are interpreted in the delegate's ord-space')
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3548-3565 (at ea57360912ed) (addIndexesReaderMerge: no reorder with an index sort; otherwise SlowCompositeCodecReaderWrapper.wrap(readers), merge.reorder(...) and SortingCodecReader.wrap(mergedReader, docMap, null))
  • lucene/core/src/java/org/apache/lucene/index/KnnVectorValues.java:136-141 (at ea57360912ed) (DocIndexIterator.index(): 'the value index (aka "ordinal" or "ord") corresponding to the current doc')
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:627-631 (at ea57360912ed) (findMerges(CodecReader...) puts all inputs in one OneMerge) and :304 (wrapForMerge may not reorder); lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:199-202 ('addIndexes is considered a forced merge', reorders it); lucene/core/src/java/org/apache/lucene/codecs/KnnVectorsWriter.java:484 and :643 (the codec merge path reads current.values.vectorValue(current.index()), the correct pattern)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/SlowCompositeCodecReaderWrapper.java:49-58, (at ea57360912ed) :331-354 (MergedDocIterator; '++ord' :342, reset :352), :824-840 (ordStart), :912-920, :1011-1019, :1060-1069 (vectorValue(ord - ordStart); comment :914-915)
  • lucene/core/src/java/org/apache/lucene/index/SortingCodecReader.java:47-54 (at ea57360912ed) (class javadoc), :244-261 (docToOrd), :265-290 (SortingValuesIterator, index() :287-290), :323-326 (delegate ord-space)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3548-3565 (at ea57360912ed) (addIndexesReaderMerge reorder path)
  • lucene/core/src/java/org/apache/lucene/index/KnnVectorValues.java:136-141 (at ea57360912ed) (DocIndexIterator.index())
  • lucene/core/src/java/org/apache/lucene/codecs/KnnVectorsWriter.java:484, (at ea57360912ed) :643 (vectorValue(current.index()); :582 is the float16 path, added later by #16383), :76-107 (mergeOneField writes vectorValue(iter.index()))
  • lucene/core/src/java/org/apache/lucene/index/SegmentMerger.java:271-276 (at ea57360912ed) (vectors merged by the codec's KnnVectorsWriter)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:301-309 (at ea57360912ed) (wrapForMerge may not reorder), :311-327 (OneMerge#reorder, @lucene.experimental :323), :627-631 (findMerges(CodecReader...) puts all inputs in one OneMerge)
  • lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:199-202 (at ea57360912ed) ('addIndexes is considered a forced merge'), :103-105 and :136 (at least one live document), :139 and javadoc :35-38 (skips the reorder on NotEnoughRAMException)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3548-3560 (at ea57360912ed) (no reorder with an index sort, or with document blocks but no parent field)
  • lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:838-841 (at ea57360912ed) (reorder returns SortingCodecReader.wrap(reader, docMap, null))
  • lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:684-697 (at ea57360912ed) (one SortingCodecReader per addIndexes call)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/KnnVectorValues.java:140 (at ea57360912ed)
  • lucene/core/src/java/org/apache/lucene/index/SlowCompositeCodecReaderWrapper.java:44-47 (at ea57360912ed) (class javadoc), :914-915, :1014-1015, :1063-1064
  • lucene/core/src/java/org/apache/lucene/index/SortingCodecReader.java:47-54, (at ea57360912ed) :265-266
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:312-313, (at ea57360912ed) :323
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3228 (at ea57360912ed) (validateMergeReader)
  • lucene/core/src/test/org/apache/lucene/index/TestSortingCodecReader.java:122, (at ea57360912ed) :227; lucene/misc/src/test/org/apache/lucene/misc/index/TestBPReorderingMergePolicy.java:146-246; lucene/test-framework/src/java/org/apache/lucene/tests/index/MockRandomMergePolicy.java (no findMerges(CodecReader...) override)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/Sorter.java:61-66 (at ea57360912ed) (DocMap.size() must equal maxDoc), :70-75 (isConsistent)
  • lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:101-113, (at ea57360912ed) :208-217, :619-624 (per-thread vectors.copy() once a partition is split), :735-770 (valueMapToDocMap); lucene/core/src/java/org/apache/lucene/index/SortingCodecReader.java:345-348, (at ea57360912ed) :392-395, :435-438 (copy() throws); SlowCompositeCodecReaderWrapper.java:903-909 (merged copy() copies each input)

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.