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.
- 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 withSortingCodecReaderby descending rank, so id 3 comes first. - Add both in one
addIndexes(sa, sb)call to a writer with no index sort whose merge policy reorders the merge:BPReorderingMergePolicy, or a customOneMerge#reorderthat returns the identity and moves nothing. - 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. - 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. - No error with assertions on or off,
CheckIndexis 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.
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.
- Ordinal: a vector's index inside a field's vector values. An iterator's
index()gives the ordinal of its current document, andvectorValue(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 itsindex()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,addIndexesjoins all inputs of one merge into a merged view (SlowCompositeCodecReaderWrapper) and asksOneMerge#reorderfor a new document order (unless an input has document blocks but no parent field).BPReorderingMergePolicycomputes one for every addIndexes merge with a live document, unless its reorderer runs out of RAM.
- The merged view's iterator numbers ordinals 0, 1, 2, ... as it walks the documents:
++ordper document (SlowCompositeCodecReaderWrapper.java:342atea57360912ed) andord = current.ordStart - 1on moving to the next input (:352). - Its
vectorValue(ord)looks the ordinal up in the input's own ordinal space,sub.vectorValue(ord - sub.ordStart)(:912-920float,:1011-1019float16,:1060-1069byte), with the comment that this random access exists "to support callers like SortingCodecReader that rely on it". That caller is theSortingCodecReaderwrapped around the view (item 4); the comment does not speak to sorted inputs of the view, and nothing excludes them. - The two agree only if each input's
index()equals its iteration position. On-disk segments satisfy that; aSortingCodecReaderdoes not:index()returnsdocToOrd[doc]andvectorValue(ord)reads the wrapped reader at that ordinal (SortingCodecReader.java:265-290,:323-326). IndexWriter.addIndexesReaderMergebuildsSortingCodecReader.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).- 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.
- 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.
| target merge policy | doc 0 (id 3) gets | documents 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 aSortingCodecReader(fromSortingCodecReader.wrap(reader, Sort), or from aSorter.DocMap, for example miscBPIndexReorderer.reorder(...), which returns one), into a writer with no index sort whose merge policy'sOneMerge#reorderreturns a DocMap for the addIndexes merge (miscBPReorderingMergePolicy, or any custom reorder, even the identity). The defaultfindMerges(CodecReader...)puts all inputs in one merge. A tool that re-sorts or imports segments into a writer configured withBPReorderingMergePolicywould hit it; no such tool is known. - Executed triggers. Inputs re-sorted by a
Sort; twoBPIndexReorderer.reorderoutputs with noSortat all; oneSortingCodecReadernext 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
BPIndexReordereroutput peraddIndexescall (no merged view is built). Regular segment merges over the built-in segment readers, by code reading: their ordinals are positional, andwrapForMergemay not reorder doc IDs; a custom codec orwrapForMergewrapper with non-positional vector ordinals was not ruled out. - Opt-in.
SortingCodecReaderandOneMerge#reorderare@lucene.experimental;BPReorderingMergePolicyis 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,
CheckIndexclean; 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
BPReorderingMergePolicyin Elasticsearch's code (code search); Solr makes it configurable (apache/solr#2641), but whether any Solr path also feedsSortingCodecReaderinputs toaddIndexeswas not checked; the OpenSearch, Solr and OpenSearch k-NN code searches hit a rate limit.
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.
/*
* 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);
}
}
}
}
}
# 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-eaand-da.
More files, environment, full output37 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)index()must return "the value index (aka "ordinal" or "ord") corresponding to the current doc"; the merged view'sindex()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'sindex()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 theSortingCodecReaderwrapped around the view, asIndexWriterbuilds 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. SortingCodecReaderdocuments 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#reorderonly 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
addIndexesinputs need positional vector ordinals;validateMergeReaderchecks 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.xSortingCodecReadercopied vectors into the new order. None of its 37 inline review comments is onSlowCompositeCodecReaderWrapperorSortingCodecReader, and none says that an input's ordinals must be positional. The codec merge path of the same commit reads each input'sindex()(KnnVectorsWriter.java:484,:643). - Why tests missed it.
TestSortingCodecReaderalways gives the target the same index sort (no reorder);TestBPReorderingMergePolicy.testReorderOnAddIndexesadds plain inputs and checks stored fields, never vectors;MockRandomMergePolicydoes 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.
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.
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
BpVectorReordereras the reorderer, each reorder thread callsvectors.copy()on the merged view (BpVectorReorderer.java:110,:619-624), which copies every input, andSorting*VectorValues.copy()throwsUnsupportedOperationException(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/coretest task (8,868 tests, 322-325 skipped) andlucene/misctest task (318 tests, 9 skipped), with assertions on and off, andcheck -x testfor 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.
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.
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.
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.
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, andaddIndexesthrough it with an identity reorder, against the per-input data: 3 runs x 150 iterations withSortingCodecReaderinputs 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 withSortingCodecReaderinputs. - 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
SortingCodecReaderallows, 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.
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 forSortingCodecReaderinputs (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, plainaddIndexes(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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-26 | a 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 vectors | no report; closest #16879, #13779, #14092, #15430, #14992 / #15553 |
| Open PRs by changed file | 2026-10-26 | the file lists of all 413 open apache/lucene PRs, filtered on the defect files and their neighbours | none changes MergedDocIterator; #16847, #16879, #13342 touch the file for other reasons |
| Commits and upstream history | 2026-10-26 | commit search (2 and 3 queries) and the history of the three defect files | the defect line is unchanged since 6053e1e3137 (#13779) |
| Mailing lists (lists.apache.org) | 2026-10-26 | 12 and 20 queries over dev@, java-user@, issues@ and commits@, plus direct searches | only PR notification mails; no report |
| JIRA archive | 2026-10-26 | 4 queries in each tool run | only items from before 10.0, before the code existed |
| Downstream (Elasticsearch, OpenSearch, OpenSearch k-NN, Solr) and the web | 2026-10-26 | issue searches, code searches for BPReorderingMergePolicy, 4 web queries | no report; Solr makes BPReorderingMergePolicy configurable; some code searches rate-limited |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main ea57360912ed | affected | this 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) | affected | the 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 | bisect | the 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 affected | the portable probe passes; its precondition shows the reorder ran over the merged view of both inputs |
| 9.8.0 and older | not applicable | no addIndexes reorder hook, BPReorderingMergePolicy or merged view (the probe does not compile) |
fix (merged-iterator-uses-sub-index.diff) | not affected | every 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 |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | Public-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 | real | Own 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), procedural | Agreed with the root cause; inputs in contract; works-as-designed case weak. |
| Intended-behaviour devil's advocate | real | Built 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 corruption | Re-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.md | Probe: 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) | reproduced | Finder's test and the independent reproduction's test fail with -ea and -da, the security probe with -da; all pass with the fix. |
Finding record, Lucene and upstream sources and repro files1,601 words
- 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 defaultlucene/coretest task (8,868 tests, 322-325 skipped) andlucene/misctest task (318 tests, 9 skipped), with assertions on and off, andcheck -x testfor 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 commit6053e1e3137and its parent7b4b0238d70.
- 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-1064lucene/core/src/java/org/apache/lucene/index/SortingCodecReader.java:47-54, (at ea57360912ed) :265-266lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:312-313, (at ea57360912ed) :323lucene/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)
- TestReorderedAddIndexesOfSortedReaders.java (JUnit test):
repro/LFV-278/src/org/apache/lucene/index/TestReorderedAddIndexesOfSortedReaders.java(102 lines)