During a merge, Lucene reads all the merged segments through one combined view. A segment in which no document has the vector field is an empty slot in that view, and the iterator and ordinal lookup of the merged vector values handle that slot, but copy() does not.
- Use
BPReorderingMergePolicy(new LogDocMergePolicy(), new BpVectorReorderer("vector"))with the default codec. Index 100 documents with a 2-dimensional float vector and commit, then 10 documents without the vector field and commit: 2 segments, the second with no vectors. - Call
forceMerge(1). You expect 1 segment with 110 documents and 100 vectors, each with its document, as when both segments have vectors (that control passes). - Instead the merge throws
NullPointerException: Cannot invoke "org.apache.lucene.index.KnnVectorValues.copy()" because "this.sub" is null:BpVectorReorderercallscopy()on the merged view in each thread that takes part in partitioning, andcopy()does not skip the empty slot of the segment without vectors. IndexWritertreats the failed merge as a tragic event and closes: 5 uncommitted documents and 1 uncommitted delete are lost (114 documents visible before, 110 on disk after). A reopened writer fails again at the next merge of that segment (3 of 3 cycles); the committed index stays clean.- With the default reorderer settings it takes 64 vectors in the merge (63 merge fine) and a segment without the field in that merge.
addIndexes(CodecReader...)fails the same way but keeps the writer open.
Applications on main that opt into BPReorderingMergePolicy with BpVectorReorderer and index documents without the vector field. With default reorderer settings, a merge that includes such a segment and holds 64 or more vectors fails and closes the writer, again after each reopen; the last commit stays clean. On 10.2.0-10.6.0 (10.6.0 by reading) only custom reorder code copying the merged view hits it.
Mechanism, terms and numbers890 words
During a merge, IndexWriter reads the segments being merged through one combined view (SlowCompositeCodecReaderWrapper) and hands it to OneMerge#reorder. For a vector field the view keeps one slot per segment, and a segment in which no document has the field gets an empty (null) slot. The iterator of the merged vector values skips empty slots and vectorValue()'s ordinal lookup handles them, but copy() calls sub.copy() on each slot and throws NullPointerException: Cannot invoke "org.apache.lucene.index.KnnVectorValues.copy()" because "this.sub" is null. BpVectorReorderer calls copy() in each thread that takes part in partitioning, which with the default settings starts once the merge holds at least 64 vectors, so with BPReorderingMergePolicy and a BpVectorReorderer a merge of 100 documents with vectors and 10 without fails. IndexWriter treats the failed merge as a tragic event: the writer closes, the changes since the last commit are lost, and a reopened writer fails again at the next merge of that segment. The committed index stays clean. The end-to-end failure is on main only (11.0.0-SNAPSHOT); the copy() defect itself came with #14092 and has shipped since 10.2.0, where only custom reorder code that copies the merged view reaches it.
- Merged view: during a merge,
IndexWriterwraps the segments being merged in oneCodecReader,SlowCompositeCodecReaderWrapper, and passes it toMergePolicy.OneMerge#reorder, which may return a new document order. With one segment there is no view (wrap()returns the segment,:49-57). - Slot: the merged vector values keep one
DocValuesSubper segment. A segment without vectors for the field gets a slot whose values arenull. - BP reordering:
BPReorderingMergePolicy(lucene/misc) renumbers the documents of each merged segment. With aBpVectorReorderer(#14097, main only) it sorts documents so that similar vectors sit together, by splitting the vectors in halves recursively on several threads.
getFloatVectorValuesof the merged view (SlowCompositeCodecReaderWrapper.java:824-841) asks every segment for its values (:830), which isnullfor a segment that indexed no vectors (CodecReader.java:235-237;LeafReader.java:215-221), and adds a slot also fornull(:831). Byte (:924-941) and float16 (:944-961) are the same.- The iterator skips empty slots (
:339,:357), and the ordinal lookup handles them (findSub,:1082onwards). ButDocValuesSub.copy()(:304-309) isreturn new DocValuesSub<>((T) (sub.copy()), docStart, ordStart);(:307) with no null check, and the threeMerged*VectorValues.copy()methods call it for every slot (:903-909,:1003-1009,:1073-1079). (Separate from this bug: the merged values also inherit the dense defaultordToDoc(), which returns its argument (KnnVectorValues.java:43-50), anditerator()returns one shared iterator (:887-889); the differential test flagged both, neither is a registered bug, and neither is needed to explain this failure.) BpVectorReorderer.computeDocMapreads the mergedFloatVectorValues(BpVectorReorderer.java:590) and, after a RAM-budget check that the default budget passes (:598-606), each thread that takes part builds onePerThreadState, whose constructor callsvectors.copy()(:110,:620-624). The first one is built inReorderTask.compute(:217) right afterif (halfLength < minPartitionSize) return;(:208-211): with the defaultminPartitionSizeof 32 that needs 64 vectors.BPReorderingMergePolicy'sreordercatches onlyNotEnoughRAMException(BPReorderingMergePolicy.java:133-145); forced merges are always reordered (:103-105), natural merges fromminNaturalMergeNumDocsdocuments, default 1 (:46).IndexWriter.mergeMiddlebuilds the view and callsreorder(IndexWriter.java:5557-5558). The exception reachesmerge()'s outercatch (Throwable t)andtragicEvent(t, "merge")(:5109-5113), which callsmaybeCloseOnTragicEvent()and sorollbackInternal()(:6090-6106): the writer rolls back to the last commit and closes.addIndexes(CodecReader...)builds the same view (:3561-3562) but callstragicEventonly forError(:3390-3392).
| case | expected | main |
|---|---|---|
forceMerge(1), 100 docs with vectors + 10 docs with vectors (control) | 1 segment, 110 vectors | the same |
forceMerge(1), 100 docs with vectors + 10 docs without the field | 1 segment, 110 docs, 100 vectors | NPE at DocValuesSub.copy, writer closed |
| the same with 63 vectors | merges | merges |
| the same with 64 vectors | merges | NPE |
5 uncommitted adds and 1 uncommitted delete before forceMerge(1) | kept | lost: 114 docs visible, 110 on disk, the deleted doc is back |
| natural merges, LogDocMergePolicy mergeFactor 2, 3 reopen cycles | merges | the writer closes in 3 of 3; segments 3, 4, 5 |
| default TieredMergePolicy, committed 20-doc flushes (up to 12 planned), the sixth without vectors | merges | the writer closes after the 9th commit; 9 ran (180 docs on disk) |
addIndexes(CodecReader...) of the same two segments | adds 110 docs | NPE; the writer stays open |
merged view copy(): float32 (empty slot first or last), byte, float16 | a copy | NPE in each |
The control, the 63/64 rows and the copy() row come from the repro tests, with default settings. The writer-state rows come from a probe that also uses the default codec, but in all four tests a ConcurrentMergeScheduler subclass whose handleMergeException only records merge-thread exceptions, and setMaxFullFlushMergeWaitMillis(0); the writer closes in IndexWriter.merge's outer catch (IndexWriter.java:5109-5113) before that handler runs, so the outcome does not depend on it. In that probe forceMerge throws IOException or, depending on timing, IllegalStateException: this writer hit an unrecoverable error; cannot complete forceMerge, with the NPE as the cause (IndexWriter.java:2284-2300).
Exposure and likelihood309 words
- Who is hit. Applications on main that opt into the expert lucene/misc combination
BPReorderingMergePolicy+BpVectorReorderer(CHANGES 11.0.0, #14097: "Binary partitioning merge policy over float-valued vector field") and whose vector field is optional: documents without embeddings, several document kinds in one index, or a batch without vectors flushed on its own. Applications that put a vector in every document, as the upstreamTestBPReorderingMergePolicydoes, never hit it. - Trigger. A forced or natural merge that includes a segment with no vector for the field and holds at least 64 vectors, and
addIndexes(CodecReader...)of such inputs, under the reproduced conditions: default reorderer settings (minPartitionSize32) and RAM budget (a budget too small for the merge throwsNotEnoughRAMExceptionbeforecopy()and the policy skips reordering), no index sort, no document blocks without a parent field. Default codec andIndexWriterConfig; no special values or timing. With the javadoc's suggestedminNaturalMergeNumDocsof 2^18, small natural merges are not reordered, but larger ones and every forced merge still are. - Effect. Indexing stops: the writer closes, the changes since the last commit are lost (114 documents visible before, 110 on disk after, an uncommitted delete undone), and each reopened writer fails again at the next merge that picks the same segment while the segment count grows. Under those conditions
forceMerge(1)over such segments fails every time. The last committed index stays clean (CheckIndex) and searchable; what a near-real-time reader already saw of the uncommitted changes disappears.addIndexes(CodecReader...)throws but keeps the writer open. - Scale. None: 64 vectors and one 10-document segment without the field.
- Released versions. The
copy()defect has shipped since 10.2.0, but there no in-tree code callscopy()on the merged view; an application's ownOneMerge#reorderthat does fails on 10.2.0 and 10.5.2 and passes on 10.1.0. - Encodings. float32, byte and float16 merged values all throw; only float32 has an in-tree caller.
The test shows that forceMerge(1) through BPReorderingMergePolicy with a BpVectorReorderer fails when one segment (here one empty document) has no vector for the field and the other has 64 vectors.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.misc.index;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.KnnFloatVectorField;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.LogDocMergePolicy;
import org.apache.lucene.index.SerialMergeScheduler;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestBpVectorReorderSegmentWithoutVectors extends LuceneTestCase {
public void testMergeSegmentWithoutVectors() throws Exception {
IndexWriterConfig iwc =
new IndexWriterConfig()
.setMergeScheduler(new SerialMergeScheduler())
.setMergePolicy(
new BPReorderingMergePolicy(
new LogDocMergePolicy(), new BpVectorReorderer("vector")));
try (Directory dir = newDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
for (int i = 0; i < 64; i++) {
Document doc = new Document();
doc.add(new KnnFloatVectorField("vector", new float[] {i, 1}));
w.addDocument(doc);
}
w.commit();
w.addDocument(new Document());
w.commit();
// expected: the two segments merge; actual: NPE from the merged view's FloatVectorValues.copy()
try {
w.forceMerge(1);
} catch (NullPointerException e) {
throw new AssertionError("expected the two segments to merge, but forceMerge threw " + e, e);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-269/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/misc/build/libs/lucene-misc-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.misc.index.TestBpVectorReorderSegmentWithoutVectors
- Expected vs actual
- Expected: the two segments merge. Actual (-ea and -da):
NullPointerException: Cannot invoke "KnnVectorValues.copy()" because "this.sub" is nullfromSlowCompositeCodecReaderWrapper$DocValuesSub.copy.
More files, environment, full output60 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: expected the two segments to merge, but forceMerge threw java.lang.NullPointerException: Cannot invoke "org.apache.lucene.index.KnnVectorValues.copy()" because "this.sub" is null
org.apache.lucene.misc.index.TestBpVectorReorderSegmentWithoutVectors.testMergeSegmentWithoutVectors(TestBpVectorReorderSegmentWithoutVectors.java:51)
Caused by: java.lang.NullPointerException: Cannot invoke "org.apache.lucene.index.KnnVectorValues.copy()" because "this.sub" is null
org.apache.lucene.index.SlowCompositeCodecReaderWrapper$DocValuesSub.copy(SlowCompositeCodecReaderWrapper.java:307)
org.apache.lucene.index.SlowCompositeCodecReaderWrapper$SlowCompositeKnnVectorsReaderWrapper$MergedFloatVectorValues.copy(SlowCompositeCodecReaderWrapper.java:906)
org.apache.lucene.misc.index.BpVectorReorderer$PerThreadState.<init>(BpVectorReorderer.java:110)
org.apache.lucene.misc.index.BpVectorReorderer$1.initialValue(BpVectorReorderer.java:623)
org.apache.lucene.misc.index.BpVectorReorderer$1.initialValue(BpVectorReorderer.java:620)
[...]KnnVectorValues#copyhas no precondition, and a segment without vectors is a documented state (getFloatVectorValuesreturns null); the merged view's iterator and ordinal lookup handle that null, butcopy()does not.- #14092 added the per-slot
copy()for this very caller;BpVectorReordererand its tests support documents without vectors. - The same class's null-slot crash for points was fixed as a bug (#12859).
The full argument284 words
KnnVectorValues#copy(KnnVectorValues.java:95-99): "Creates a new copy of this {@link KnnVectorValues}. This is helpful when you need to access different values at once, to avoid overwriting the underlying vector returned." No precondition.LeafReader#getFloatVectorValues(LeafReader.java:215-221): "... or null if no {@link FloatVectorValues} were indexed. The returned instance should only be used by a single thread." So a reorderer that reads the vectors from several threads at once needs one copy per thread (or must serialize access), and a segment without vectors is an expected state.OneMerge#reorder(MergePolicy.java:311-325) "will be called when index sorting is disabled on a merged view of the {@link OneMerge}": this view.BPReorderingMergePolicy(:34-39): "When reordering doesn't have enough RAM, it simply skips reordering in order not to fail the merge."- Sparse vectors are supported. The merged view stores
nullslots on purpose (since #12622, 2023) and handles them in its iterator and ordinal lookup.BpVectorReorderersorts "docs with no value at the end" (BpVectorReorderer.java:743), itscheckFieldreturns null for an absent field instead of throwing (:699-713), andTestBpVectorReorderer.testIndexReorderSparsechecks that documents without vectors "sort at the end" (:387-428), on a single segment, so without a merged view. - History. #14092 (@msokolov, 2024-12-31) added the per-slot
copy(); its PR body says the earlier shallow copy "was a bug introduced with #13779 ... I came across test failures when working on reviving a vector-based binary-partitioning merge policy". No review comments; segments without the field are not discussed. - Precedents. #12858 / #12859 (2023): "Avoid null PointValues when merging points in SlowCompositeCodecReaderWrapper". #14123 / #14125 (2025): "Add null checks to SortingCodecReader" for a segment without vectors in a reordering or
addIndexesmerge.
The devil's-advocate check170 words
The strongest case for "intended": the wrapper is package-private and "primarily targeted at merging, not searching" (SlowCompositeCodecReaderWrapper.java:43-47); KnnVectorValues, getFloatVectorValues and OneMerge#reorder are @lucene.experimental; KnnVectorsReader says "The behavior is undefined if the given field doesn't have KNN vectors enabled on its FieldInfo" (KnnVectorsReader.java:64-69); the vector values of SortingCodecReader throw UnsupportedOperationException from copy() (SortingFloatVectorValues.copy(), SortingCodecReader.java:345-348); the policy's javadoc names only BPIndexReorderer; and BpVectorReorderer is new and untested with such segments.
Why it fails: "experimental" allows API changes, not an NPE on legal data built with public API and default settings. The wrapper never asks a segment's KnnVectorsReader about an absent field; it calls CodecReader's guarded getter, which returns null by contract, and stores that null itself. An UnsupportedOperationException is a deliberate signal; this NPE is an accident in the method #14092 wrote for this caller. #14097 made BpVectorReorderer pluggable into BPReorderingMergePolicy, and TestBPReorderingMergePolicy plugs it in. The missing test is a coverage gap. Every reviewer rated the intended-behaviour argument weak.
Recommended Return the empty slot unchanged in DocValuesSub.copy()
Guard the empty slot in DocValuesSub.copy(): if (sub == null) return this; (+4 lines in SlowCompositeCodecReaderWrapper.java). Validated with gradle: both repro classes pass with assertions on and off, and TestAddIndexes, TestSortingCodecReader, TestGetVectorCountCodecWrappers, the whole misc module and the nightly TestBPReorderingMergePolicy pass.
private record DocValuesSub<T extends KnnVectorValues>(T sub, int docStart, int ordStart) {
@SuppressWarnings("unchecked")
DocValuesSub<T> copy() throws IOException {
if (sub == null) {
// this reader has no vectors for the field: nothing to copy
return this;
}
return new DocValuesSub<>((T) (sub.copy()), docStart, ordStart);
}
}All options and trade-offs5 options · 578 words
One fix was built and validated in a scratch worktree; the other options are untested or not recommended. The null guard does not fix two related, separate hunt findings in the same code: LFV-278 (the merged iterator numbers ordinals by position, so a reordered addIndexes of two or more SortingCodecReader inputs writes vectors onto the wrong documents) and LFV-404 (BpVectorReorderer fails with UnsupportedOperationException on SortingCodecReader inputs, whose vector values' copy() throws on purpose, SortingCodecReader.java:345-348).
SlowCompositeCodecReaderWrapper.java only, +4 lines. The record is immutable and an empty slot has no state, so returning it is safe; slots with vectors are copied as before, so #14092's thread-safety fix stays. One place fixes float32, byte and float16 and every consumer of the merged view's copy(): BpVectorReorderer and custom OneMerge#reorder code, whether a merge or addIndexes(CodecReader...) reaches them.
private record DocValuesSub<T extends KnnVectorValues>(T sub, int docStart, int ordStart) {
@SuppressWarnings("unchecked")
DocValuesSub<T> copy() throws IOException {
if (sub == null) {
// this reader has no vectors for the field: nothing to copy
return this;
}
return new DocValuesSub<>((T) (sub.copy()), docStart, ordStart);
}
}Trade-offs
- No behaviour change for segments with vectors; no extra allocation.
- The same change applies to the 10.x branches, whose
DocValuesSub.copyreads the same (10.x has no float16 values). Add a CHANGES entry under Bug Fixes. - A regression test should cover a merge, not only
copy(): the end-to-end test checks the vector content and the document mapping after the reordered merge.
What the checks validated
- Clean scratch worktree at
a9779ef3c193, gradle, seedDEADBEEF. Stock:TestSlowCompositeVectorValuesCopy4 of 5 fail andTestBpVectorReorderSegmentWithoutVectors1 of 2 fail. With the fix: both pass, 7 of 7, with assertions on and off. - Closest suites with the fix:
TestAddIndexes(39 tests),TestGetVectorCountCodecWrappers(1),TestSortingCodecReader(3) and the wholelucene/miscmodule (24 classes, 317 tests, 9 skipped, among themTestBPReorderingMergePolicy,TestBpVectorReordererandTestBPIndexReorderer): 0 failures.TestBPReorderingMergePolicywith-Ptests.nightly=true: 3 of 3 pass, none skipped. - With the fix and both tests in place, these pass:
checkGoogleJavaFormat(core, misc),ecjLintMainandecjLintTest(core),ecjLintTest(misc),forbiddenApisMainandforbiddenApisTest(core) andforbiddenApisTest(misc). The worktree was removed afterwards. - Shadowed onto the stock jars: the repro passes 7 of 7 with assertions on and off; the writer-state probe passed 4 of 4.
subsCopy.add(sub.sub() == null ? sub : sub.copy()) in MergedFloatVectorValues, MergedFloat16VectorValues and MergedByteVectorValues.copy().
Trade-offs
Same behaviour; three places to keep in sync. Untested.
Skip segments whose values are null when the merged values are built.
Trade-offs
Removes the null state. The remaining slots keep their own docStart and ordStart, so the iterator and findSub look compatible, but that needs validation. The slot list would be empty only if no segment has the field, which the field-info check in CodecReader's getter normally prevents; a direct call on the vectors reader would then make MergedDocIterator's constructor call it.next() on an empty list (:322), so it needs a guard. Touches three getters for the same effect as the null guard. Untested.
Go back to the shallow copy of the slot list.
Trade-offs
Brings back the shared, thread-unsafe copy that #14092 fixed.
Avoid the copy, or catch the exception and skip reordering.
Trade-offs
Leaves the merged view's copy() broken for custom OneMerge#reorder code.
Downstream: a public code search found no product that wires BpVectorReorderer into a merge policy; an application that adopts it on main loses indexing whenever documents without the vector field end up in a segment of their own.
A randomized differential test built merged views over random multi-segment inputs, with fields missing from some segments, and compared a broad set of merged-view APIs with the source segments; in 60 iterations it reported, among other mismatches, 102 NullPointerExceptions from copy() of the merged vector values. The failing frame led to BpVectorReorderer, then to an end-to-end repro.
The method in full173 words
Method: randomized differential testing of the merged view against its source segments (no model checker, solver or prover found it).
- The test builds random multi-segment inputs (a target of 2-5 segments, which it can overshoot) in which some fields are missing from some segments, wraps them in the merged view and compares a broad set of merged-view APIs, the vector values'
copy()included, with the source segments. Over 60 random iterations without empty segments it reported 592 mismatches of 5 kinds, among them 102 of the kind "vector-copy-exception: NullPointerException"; the others (getOffHeapByteSizeexceptions, the inheritedordToDoc()and the sharediterator()) are not this bug. - The failing frame (
DocValuesSub.copy) led to the one in-tree caller that copies the merged view,BpVectorReorderer, and an end-to-end JUnit throughBPReorderingMergePolicyreproduced it on the stock jars. - A Z3 model of the same unit proved the merged view's ordinal lookup (
findSub) correct with empty slots; it gave no signal forcopy(), so this is not counted as a formal find.
No prior report found (2026-10-26, re-checked 2026-10-28) in Lucene's GitHub issues, PRs and commits, JIRA, the mailing lists including the CI mails, four downstream projects or the web. Nearest: #12859 (the same class's null-slot crash for points) and #14125 (null checks in SortingCodecReader).
Searches4 searches
Verdict new: no report by anyone. Open #16879 (adds getMergeSources to the same reader) and #15430 (adds if (floats == null) return null; in computeDocMap; checkField already returns null when the merged field infos lack the field, and the merged view returns non-null values when they have it, so the guard does not change this case) touch the area but would not fix it. A #15003 PR comment shows a different error on the same call path (AssertionError: Called on the wrong instance). Gaps: GitHub search covers titles, bodies and comments, not the diffs of closed unmerged PRs; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@ and builds@; JIRA archive | 2026-10-26 | the hunt's prior-report tool, three runs: SlowCompositeCodecReaderWrapper, BpVectorReorderer, BPReorderingMergePolicy, DocValuesSub, copy, "this.sub is null", "background merge hit exception", "segment without vectors" | no report; nearest #14123 / #14125 and #12858 / #12859 |
| GitHub (apache/lucene, Elasticsearch, OpenSearch, OpenSearch k-NN, Solr) | 2026-10-26 | 86 GraphQL searches | no report; downstream BP-reordering items are feature requests or configuration changes |
| Mailing lists, JIRA, open PRs, history, the web | 2026-10-26 | 45 direct list queries, the builds@ CI mails for TestBPReorderingMergePolicy read in full; a JQL query; the changed files and patches of all 413 open PRs; git log -S 'sub.copy()'; 4 web searches | CI failures with other causes (OutOfMemoryError, "The heap is empty", a codec footer mismatch); open #16879 and #15430 would not fix it |
| GitHub issues and PRs (apache/lucene); upstream commit lists | 2026-10-28 | BpVectorReorderer; SlowCompositeCodecReaderWrapper copy; "this.sub" is null; commits of both files | nothing new (#16250 is RoaringDocIdSet); neither file changed after a9779ef3c193 |
Introduced by #14092 (68051f1b9f5, 2024-12-31), first released in 10.2.0, where no in-tree code reaches it. The stock end-to-end trigger, BpVectorReorderer in BPReorderingMergePolicy (#14097), is on main only; 10.0.0 and 10.1.0 are clean.
Versions tested8 rows
Introduced by #14092, "SlowCompositeCodecReaderWrapper copies sub-vector values for thread-safety" (commit 68051f1b9f5, subject "SlowCompositeCodecReaderWrapper must copy its sub-vector values to maintain thread-safety", @msokolov, 2024-12-31, milestone 10.2.0; branch_10x backport 0bfa27513dce, contained in the tags 10.2.0 to 10.6.0). It replaced the shallow copy of the slot list (#13779, 10.0.0) with a per-slot DocValuesSub.copy() without a null check. Between 10.1.0 and 10.2.0 the backport is the only commit touching the file; reverse-applying it to the 10.2.0 file gives the 10.1.0 file, and shadowing that class onto 10.2.0 makes both failing tests pass.
BpVectorReorderer came with #14097 (02a09f6ab12, 2025-01-07), in no release tag; #16383 (bfe5c847d3c, 2026-07-27, main only) added the float16 merged values with the same copy(). Not fixed upstream as of 20ced71c1e6e (2026-10-27).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | end to end and core: the repro fails 5 of 7, assertions on and off, also through gradle; the writer closes |
main ea57360912ed | affected | the finder's repro fails 4 of 6, on and off; natural and tiered merges close the writer |
| 10.2.0, 10.3.0, 10.3.2, 10.4.0, 10.5.2 | custom reorder only | the core copy() NPE (same-package test; on 10.2.0 and 10.5.2 also a public-API custom OneMerge#reorder), on and off on 10.2.0 and 10.5.2; no BpVectorReorderer in these releases |
| 10.2.1, 10.2.2, 10.3.1, 10.5.0, 10.5.1 | by reading | DocValuesSub.copy reads the same; not executed |
| 10.2.0 with the #14092 backport reverted | bisect | both failing tests pass |
| 10.0.0, 10.1.0 | not affected | copy() is shallow there; the tests pass |
| 9.12.3 | API absent | no KnnVectorValues#copy() (compile error) |
fix, scratch worktree at a9779ef3c193 | not affected | repro 7 of 7, closest core suites, the misc module (317 tests) and the nightly TestBPReorderingMergePolicy pass |
The finder's repro, an independent reproducer's own tests, a root-cause review by another model family (OpenAI GPT-5.6), a devil's-advocate check and an adjudication all found it real; a writer-state probe showed that the writer closes and loses uncommitted changes. This doc re-ran it all on main a9779ef3c193.
Checks and verdicts343 words
The checks below ran on main ea57360912ed unless stated; the merged view, the reorderer and the merge code are the same on a9779ef3c193.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Differential-test signature; an end-to-end and a same-package JUnit fail 4 of 6, assertions on and off; the fix passes them and the upstream BP and vector-count suites. |
| cross-model review (refute lens) | not real (low), on procedure only | OpenAI GPT-5.6, reading only: it could not run code, agreed with the root cause and rated the intended-behaviour argument weak. |
| cross-model review (intended lens) | real (medium) | OpenAI GPT-5.6: the inputs are in contract and the intended-behaviour argument is weak. |
| independent reproducer | real | Own tests fail 6 of 10, on and off; found addIndexes(CodecReader...) as a second trigger and a public-API custom reorder that fails on 10.2.0 and 10.5.2; its own one-line fix passes 10 of 10. |
| root-cause review | real (medium) | OpenAI GPT-5.6, reading only (it ran no code): the first invalid step is DocValuesSub.copy, one cause for all three encodings. Separate re-runs on two seeds fail 4 of 6 and pass with the fix. |
| devil's advocate | real (high) | No intended, misuse or out-of-contract argument survives; its re-run fails 4 of 6, on and off. |
| adjudication | confirmed (high) | The reproducer's tests with fresh seeds fail 6 of 10, on and off; the fix passes 10 of 10. Rated it a crash before the writer-state probe showed the writer closes. |
| writer-state probe | writer closes; not security-sensitive | forceMerge, natural and default tiered merges close the writer and lose uncommitted changes; 3 of 3 reopens fail again; addIndexes keeps the writer open; CheckIndex clean; with the fix 4 of 4 pass. |
| registry refresh | still reproduces | The finder's repro fails 4 of 6 on a9779ef3c193, on and off, with the same messages. |
| this doc's runs | reproduced; fix validated | New repro 5 of 7 fail, on and off; 63 vs 64 vectors; the writer-state probe on a9779ef3c193; the fix validated with gradle. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 482 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,347 words
- Registry title
- The merged vector values of SlowCompositeCodecReaderWrapper throw NullPointerException from copy() when one merged segment has no vectors for the field, so merges through BPReorderingMergePolicy with BpVectorReorderer fail and close the IndexWriter, again after every reopen
- Area
- index / merged CodecReader view (SlowCompositeCodecReaderWrapper vector values); misc / BP merge-time reordering
- Priority
- Medium, rank 256: Like LFV-100, this is a merged-view NPE when one segment lacks the vector field, but every BP-vector-reordered merge closes the writer again after each reopen, and the stock path is unreleased.
- Impact class
- index unusable
- Impact note
- Applications on unreleased main (11.0.0-SNAPSHOT) that opt into the expert lucene/misc BPReorderingMergePolicy with a BpVectorReorderer and have a segment in which no document carries the (optional) vector field are hit on every forced or natural merge that includes that segment and holds at least 64 vectors (2 x the default minPartitionSize of 32, read from code): the merge throws, IndexWriter closes itself as a tragic event and discards all uncommitted changes, and a reopened writer closes again at the next such merge, while the committed index stays clean and searchable; addIndexes(CodecReader...) under the same policy throws but leaves the writer open; any index size, default codec and IndexWriterConfig otherwise; on released 10.2.0-10.6.0 (10.6.0 by reading) the same NullPointerException is reachable only through an application's own OneMerge#reorder or IndexReorderer that calls copy() on the merged view.
- Summary
- You use BPReorderingMergePolicy with a BpVectorReorderer on a float vector field, and one segment holds only documents without that optional field, for example a batch of documents without embeddings that was flushed on its own. You expect merges to combine it with the other segments, as they do when every segment has vectors. Instead the merged view's FloatVectorValues.copy() throws NullPointerException ('Cannot invoke KnnVectorValues.copy() because this.sub is null'), IndexWriter treats the failed merge as a tragic event, closes itself and drops every change since the last commit, and the same merge fails again after each reopen.
- Introduced
- 68051f1b9f5a052b9a2b4274ad7197eafb4a8caf, 'SlowCompositeCodecReaderWrapper must copy its sub-vector values to maintain thread-safety (#14092)' by msokolov (merged 2024-12-31, milestone 10.2.0, no review comments, no CHANGES entry); branch_10x backport 0bfa27513dce, same day, the commit the 10.2.0 release contains [exec: between 10.1.0 and 10.2.0 it is the only commit touching SlowCompositeCodecReaderWrapper.java; reverse-applying it to the 10.2.0 file gives the 10.1.0 file byte for byte, and shadowing that class onto the stock 10.2.0 jars turns both failing tests into passes]. It replaced the shallow copy of the sub list (#13779, 10.0.0) with DocValuesSub.copy(), which calls sub.copy() with no null check, to fix a real thread-safety problem; the null subs themselves date from #12622 (f7cab164501, 2023) and are handled everywhere else. The end-to-end in-tree trigger came with #14097 (02a09f6ab12, 2025-01-07, main only), whose BpVectorReorderer copies the merged view once per thread; #16383 (bfe5c847d3c, 2026-07-27, main only) added the float16 variant with the same pattern (2024-12-31)
- First affected version
- 10.2.0 [exec] for the core copy() NullPointerException (same-package test and a public-API custom OneMerge#reorder that copies the merged view, -ea and -da; 10.1.0 passes); the stock end-to-end failure (BPReorderingMergePolicy + BpVectorReorderer) is unreleased, main only (11.0.0-SNAPSHOT), because BpVectorReorderer is in no release
- Affected versions
- core copy() defect: 10.2.0, 10.3.0, 10.3.2, 10.4.0, 10.5.2 and main ea57360912ed [exec: the follow-up's history runs, the finder's release runs and the independent reproduction's; -da also on 10.2.0, 10.5.2 and main]; 10.2.1, 10.2.2, 10.3.1, 10.5.0, 10.5.1, 10.6.0 and the branch_10x, branch_10_6 and branch_10_5 tips of 2026-10-24 [read: DocValuesSub.copy reads the same]; not affected: 10.0.0 and 10.1.0 [exec pass]; 9.12.3 and older: API absent (no KnnVectorValues, no copy()) [exec: compile error on 9.12.3]. End-to-end BPReorderingMergePolicy + BpVectorReorderer failure (forceMerge, natural merges, addIndexes(CodecReader...)): main only [exec, -ea and -da]; BpVectorReorderer is absent from the 10.2.0, 10.3.0 and 10.5.2 misc jars [checked by the intended-behaviour check on the cached jars]. float32, byte and (main only) float16 merged values all fail [exec], but only the float32 path has an in-tree caller. Not fixed upstream as of main ea57360912ed [read, 2026-10-26]
- 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
- Upstream and prior reports
- none found (2026-10-26, read-only; the prior-report record: three scripted prior-report searches over apache/lucene issues and PRs, commits, the dev@, java-user@, issues@, commits@ and builds@ lists and the JIRA archive; 86 GitHub GraphQL searches over apache/lucene and four downstream projects; 45 direct mailing-list queries with the builds@ Jenkins mails for TestBPReorderingMergePolicy read in full; a direct JQL query; the changed files and patches of all 413 open PRs; 4 web searches; git log -S 'sub.copy()'). Related, not the same bug: #14123 / #14125 (2025-01-09, an NPE in SortingCodecReader for a segment without vectors in a reordering or addIndexes merge, fixed only in SortingCodecReader); #12858 / #12859 (2023, null PointValues in the same wrapper's points reader, the precedent for a null-check fix); #14092 (introduced it); #14097 (added the caller); #13779; #13626 / #13641; a #15003 review comment with an AssertionError on the same call path; open #16879 (adds getMergeSources to the same file) and #15430 (null check for a merged view with no vectors at all) touch the area but would not fix it
- Lucene commit tested
- main
a9779ef3c193(2026-10-26) with assertions on and off, through gradle in a clean scratch worktree and through a standalone JUnit runner on the built jars; the fix in the same worktree (repro, closest core suites, the whole misc module, the nightlyTestBPReorderingMergePolicy, format and lint checks). Earlier runs of the same defect: the release jars 9.12.3, 10.0.0, 10.1.0, 10.2.0, 10.3.0, 10.3.2, 10.4.0 and 10.5.2, and mainea57360912ed. Upstream main20ced71c1e6e(2026-10-27) has the same code (read).
- Finding record
lucene/core/src/java/org/apache/lucene/index/SlowCompositeCodecReaderWrapper.java:304-309 (at ea57360912ed) (DocValuesSub.copy: 'new DocValuesSub<>((T) (sub.copy()), docStart, ordStart)' with no null check), :339 and :357 (MergedDocIterator checks 'current.sub != null'), :826-840 (getFloatVectorValues adds a DocValuesSub for every reader, also when its values are null; siblings for float16 and byte), :903-909, :1003-1009, :1073-1079 (MergedFloat/Float16/ByteVectorValues.copy call sub.copy() for every entry)lucene/core/src/java/org/apache/lucene/index/KnnVectorValues.java:96 (at ea57360912ed) (copy(): 'Creates a new copy of this KnnVectorValues', no precondition)lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:108-111 (at ea57360912ed) (PerThreadState constructor calls vectors.copy()), :208-211 (halfLength < minPartitionSize returns before the first PerThreadState), :217 (threadLocal.get()), :578-593 (computeDocMap reads the merged view's FloatVectorValues), :614-627 (CloseableThreadLocal builds PerThreadState)lucene/misc/src/java/org/apache/lucene/misc/index/AbstractBPReorderer.java:25 (at ea57360912ed) (DEFAULT_MIN_PARTITION_SIZE = 32)lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:46 (at ea57360912ed) (minNaturalMergeNumDocs = 1), :133-145 (reorder catches only NotEnoughRAMException)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3562 (at ea57360912ed) (addIndexes(CodecReader...) calls merge.reorder on the merged reader), :5558 (mergeMiddle calls merge.reorder on the merged view), :5109-5113 (merge(): catch Throwable -> tragicEvent(t, "merge"))a Lucene main checkout SECURITY.md:28 (downstream application handles untrusted input)
- The finding in 30 seconds
a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/SlowCompositeCodecReaderWrapper.java:49-57, 304-309, 339, 357, 824-841, 903-909, 924-961, 1003-1009, 1073-1079, 1082-1106a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/CodecReader.java:235-237; LeafReader.java:215-221a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:110, 208-217, 578-593, 610-628; AbstractBPReorderer.java:25a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:46, 103-105, 133-145a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3388-3392, 3561-3562, 5030-5058, 5065-5113, 5557-5558
- Who is affected
a Lucene main checkout lucene/CHANGES.txt:162 (GITHUB#14097); lucene/misc/src/test/org/apache/lucene/misc/index/TestBPReorderingMergePolicy.java:40-80 (a vector in every document)a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:46, 54-56, 103-117a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:598-606 (NotEnoughRAMException before copy()); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5545-5558 (reorder skipped with an index sort or blocks without a parent field)
- Why it looks unintended
a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/KnnVectorValues.java:95-99; LeafReader.java:215-221; MergePolicy.java:311-325a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:34-39a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:699-713, 735-743; a Lucene main checkout lucene/misc/src/test/org/apache/lucene/misc/index/TestBpVectorReorderer.java:387-428a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/SlowCompositeCodecReaderWrapper.java:43-47; a Lucene main checkout lucene/core/src/java/org/apache/lucene/codecs/KnnVectorsReader.java:64-69; a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/SortingCodecReader.java:345-348
- TestBpVectorReorderSegmentWithoutVectors.java (JUnit test):
repro/LFV-269/src/org/apache/lucene/misc/index/TestBpVectorReorderSegmentWithoutVectors.java(55 lines)
Made in parallel from an executed probe on main a9779ef3c193 (100 committed documents with vectors, 10 without, 5 uncommitted, forceMerge(1), two reopens); the PNG is the final summary frame.
assets/LFV-269/bug.png(482 KB)
