Two calls on one merged iterator are enough, on an index where one segment lacks the field.
- A Lucene index is a list of segments. Each segment has its own doc-values iterator per field; a segment in which no document has the field has none (
null). MultiDocValues.getNumericValues(reader, field)stitches the segments' iterators into one iterator over the whole index.IndexWriterhands the same stitched iterators to a customMergePolicy.OneMerge#reorderduring a merge.- Besides
nextDoc(), a doc-values iterator can jump forward in two ways:advance(t)jumps to the first document at or aftertthat has a value;advanceExact(t)moves to exactlytand returns whetherthas a value. Both are legal on one iterator, as long as targets never go backwards.
advanceExact(0) loads segment 0, finds no values and keeps a null iterator; advance(1) stays in segment 0, skips the reload and calls the null iterator. Select the still to open it at full size. Generated with AI, checked against the code.- Index two segments: segment 0 holds docs 0 and 1 without the numeric doc-values field
f, segment 1 holds doc 2 withf = 42. Open one merged iterator over both:MultiDocValues.getNumericValues(reader, "f"). - Call
advanceExact(0). It returns false, correctly: doc 0 has no value. The iterator now sits on doc 0. - Call
advance(1). You expect 2, the next document with a value:nextDoc()from the same spot returns 2, and the sorted-numeric view gives 2 for the same layout. - Instead it throws
NullPointerException: ... because "this.currentValues" is null.advanceExactrecorded "segment 0 has no values" as a null per-segment iterator, andadvanceuses that iterator without a check while the target stays in segment 0. - The binary and norms views fail the same way, with assertions on or off, in every release from 7.0.0 to 10.6.0. It never returns a wrong value, and no Lucene code calls the two methods in this order.
Only application code that reads these merged iterators itself (direct MultiDocValues use, or a custom OneMerge#reorder from 9.9.0) and calls advance() after a false advanceExact() on a sparse field; no Lucene caller does. Always a loud exception, never a wrong value. Uncaught inside reorder, it closes the IndexWriter (with merge-on-commit, uncommitted documents are rolled back).
Mechanism, terms and numbers569 words
MultiDocValues.getNumericValues, getBinaryValues and getNormValues return one doc-values iterator over all segments of a reader. When advanceExact(target) lands in a segment that has no values for the field, it returns false (correct) and remembers that segment as a null per-segment iterator while marking the segment as loaded. A following advance(t) with t in the same segment skips its "load the segment" branch and calls currentValues.advance(...) on that null: NullPointerException at MultiDocValues.java:263 (numeric), :396 (binary) or :130 (norms). The same happens for advance(NO_MORE_DOCS) after advanceExact landed in a field-less last segment. The call sequence is legal under the DocValuesIterator and DocIdSetIterator contracts, and the sorted, sorted-set and sorted-numeric views of the same class return the right document because they substitute empty iterators for missing segments. IndexWriter hands the same views to a custom MergePolicy.OneMerge#reorder (9.9.0+), where an uncaught exception closes the writer. It came with advanceExact in 7.0.0 (LUCENE-7462, 2016) and is unchanged on main. The result is always a loud exception, never a wrong value, and no Lucene caller mixes the two calls. A three-line guard in each advance() fixes it.
- Merged view: the anonymous
NumericDocValues/BinaryDocValuesclasses returned byMultiDocValues.getNumericValues,getBinaryValuesandgetNormValueswhen a reader has two or more segments. Each keepscurrentLeaf,currentValues(the current segment's own iterator, ornull) andnextLeaf(the next segment to load). - Field-less segment: a segment in which no document has the field, so
LeafReader#getNumericDocValues(field)returnsnull. Normal for optional or sparse fields, or a field added to the schema later.
- Numeric
advanceExact(target)(MultiDocValues.java:274-296) loads the target's segment if it is not loaded yet (readerIndex >= nextLeaf,:283):currentValues = currentLeaf.reader().getNumericDocValues(field)(:288), which isnullfor a field-less segment, andnextLeaf = readerIndex + 1(:289). It setsdocID = targetDocID(:291) and returns false (:292-293). - Numeric
advance(target)(:241-271) reloads only when the target lies in a segment not loaded yet (if (readerIndex >= nextLeaf),:250); inside that branch it already handles a field-less segment (:259-261). For a target in the segment thatadvanceExactjust loaded,readerIndex < nextLeaf, so it skips the branch and callscurrentValues.advance(targetDocID - currentLeaf.docBase)(:263) onnull. ReaderUtil.subIndexnever returnsleaves.size()(ReaderUtil.java:74-94): a target>= maxDocmaps to the last segment. So afteradvanceExactlands in a field-less last segment,advance(NO_MORE_DOCS)throws too; thereaderIndex == leaves.size()check at:251cannot fire.- Binary (
advance:374-404, NPE at:396;advanceExact:407-429) and norms (advance:108-138, NPE at:130;advanceExact:141-163) have the same code. - The sorted, sorted-set and sorted-numeric views fill an array of per-segment iterators up front with
DocValues.empty*()for a field-less segment (sorted-numeric:463-473), so theircurrentValuesis nevernullfor a loaded segment, and they return the right document for the same calls.
Two segments, [doc 0, doc 1: no field] [doc 2: f = 42]: advanceExact(0) = false, then advance(1) throws NullPointerException at MultiDocValues$2.advance:263; expected 2. nextDoc() from the same state and the sorted-numeric view both give 2. The hunt's exhaustive harness found the NPE in 827 of the 70,662 call sequences of length up to 3 for each of the numeric, binary and norms views, and 0 mismatches for the sorted views.
Exposure and likelihood264 words
- Who is hit. Code that holds a merged numeric, binary or norms view and calls
advance(t)afteradvanceExact(e)returned false, witheandtin a segment without the field (ort >= maxDocafter landing in a field-less last segment). Two entry points:MultiDocValues.getNumericValues/getBinaryValues/getNormValues(public class, tagged@lucene.experimentaland@lucene.internal), and, since 9.9.0, the mergedCodecReaderthatIndexWriterpasses to a customMergePolicy.OneMerge#reorder(@lucene.experimental), whose norms, numeric and binary doc values are these views (SlowCompositeCodecReaderWrapper.java:291,:412,:417). - No Lucene caller is affected.
DocumentDictionaryusesadvanceonly; the infix suggesters open a fresh view per hit; Luke usesadvanceExactonly; the in-tree reorderers and merge consumers iterate. - Ordinary data. Default codec and config; 2 segments and 3 documents are enough. A field-less segment is normal for optional or sparse fields.
- What happens. A
NullPointerExceptionat the first suchadvance(), with assertions on and off. No wrong value, no hang, nothing written. - Inside
reorderit closes the writer (executed on main and 10.5.2). If the exception escapes areorderoverride,IndexWritertreats it as a tragic merge failure and closes:forceMerge(1)throws, a reopened writer retries the same merge and closes again, and with merge-on-commit (on by default)commit()fails and the documents added since the last commit are rolled back. The committed index stays intact (CheckIndex clean). With the fix the merge completes. - How likely. Rare: code that mixes the two calls fails on ordinary sparse data, so it would most likely surface during development. No prior public report since 7.0.0 (2017).
One JUnit test, one method: two commits make two segments, then advanceExact(0) and advance(1) on the merged numeric iterator. Public API only (MultiDocValues is a public class), deterministic, single thread, about 1.4 s; fails with assertions on and off. It catches the NPE only to print expected vs actual.
/*
* 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 org.apache.lucene.document.Document;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestMultiDocValuesAdvanceAfterAdvanceExact extends LuceneTestCase {
public void testAdvanceAfterAdvanceExactOnSegmentWithoutField() throws Exception {
IndexWriterConfig iwc = new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE);
try (Directory dir = newDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
w.addDocument(new Document());
w.addDocument(new Document());
w.commit();
Document doc = new Document();
doc.add(new NumericDocValuesField("f", 42));
w.addDocument(doc);
w.commit();
try (DirectoryReader reader = DirectoryReader.open(dir)) {
NumericDocValues dv = MultiDocValues.getNumericValues(reader, "f");
assertFalse(dv.advanceExact(0));
int next;
try {
next = dv.advance(1);
} catch (NullPointerException e) {
// segment 0 (docs 0, 1) lacks the field: expected advance(1) == 2, actual NPE
throw new AssertionError("expected advance(1) == 2, got " + e, e);
}
assertEquals(2, next);
}
}
}
}
cp TestMultiDocValuesAdvanceAfterAdvanceExact.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestMultiDocValuesAdvanceAfterAdvanceExact -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false instead (same result)
- Expected
advance(1)returns 2, the next document with a value, asnextDoc()and the sorted-numeric view give from the same state.- Observed
NullPointerException: Cannot invoke "org.apache.lucene.index.NumericDocValues.advance(int)" because "this.currentValues" is nullatMultiDocValues.java:263, with assertions on and off.
More files, environment, full output238 words
The test sits in org.apache.lucene.index next to the upstream TestMultiDocValues but uses no package-private access. Its result does not depend on the seed (it uses no randomness the outcome depends on; the test framework's random codec does not matter).
Not shown by this one test, all from the same null currentValues and executed elsewhere: the binary and norms views, the public OneMerge#reorder path (9.9.0+), and advance(NO_MORE_DOCS) after advanceExact lands in a field-less last segment.
As the regression test: the same test unchanged, for example as a new method of TestMultiDocValues. It fails on stock main and passes with fix A or B, with assertions on and off (gradle). :lucene:core:check -x test (google-java-format, ecj lint, forbidden APIs, licenses) passes with fix A and this test in place.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); this test also fails on 10.6.0; every release from 7.0.0 to 10.6.0 is affected; 6.x has no doc-values iterator API |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp TestMultiDocValuesAdvanceAfterAdvanceExact.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestMultiDocValuesAdvanceAfterAdvanceExact -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false instead (same result)
# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (the same with =false, and on 10.6.0)
TestMultiDocValuesAdvanceAfterAdvanceExact > testAdvanceAfterAdvanceExactOnSegmentWithoutField FAILED
java.lang.AssertionError: expected advance(1) == 2, got java.lang.NullPointerException: Cannot invoke "org.apache.lucene.index.NumericDocValues.advance(int)" because "this.currentValues" is null
at __randomizedtesting.SeedInfo.seed([DEADBEEF:75B1A4024E2D61B9]:0)
at org.apache.lucene.index.TestMultiDocValuesAdvanceAfterAdvanceExact.testAdvanceAfterAdvanceExactOnSegmentWithoutField(TestMultiDocValuesAdvanceAfterAdvanceExact.java:45)
Caused by:
java.lang.NullPointerException: Cannot invoke "org.apache.lucene.index.NumericDocValues.advance(int)" because "this.currentValues" is null
at org.apache.lucene.index.MultiDocValues$2.advance(MultiDocValues.java:263)
at org.apache.lucene.index.TestMultiDocValuesAdvanceAfterAdvanceExact.testAdvanceAfterAdvanceExactOnSegmentWithoutField(TestMultiDocValuesAdvanceAfterAdvanceExact.java:42)
1 test completed, 1 failed- The contract allows it: after
advanceExact(0),docID()is 0, andadvance(t)is defined for anyt > docID(). - The sorted views of the same class and the per-segment codec iterators return 2 for the same calls.
advance()was written beforeadvanceExact()existed (2016); the new method created a null state the old checks never covered.
The full argument305 words
- Contract.
DocValuesIterator#advanceExact(DocValuesIterator.java:24-33): "After this method returns,docID()returnstarget"; the only calls it declares illegal after a false return areintoBitSetanddocIDRunEnd(:30-31).DocIdSetIterator#advance(DocIdSetIterator.java:137-163) is undefined only fortarget <= currentor after exhaustion; fortarget > currentit "behaves as if written" as anextDoc()loop (:144-153), and it "may be called withNO_MORE_DOCS" (:157-159). The repro obeys both. Nothing inMultiDocValues' javadoc narrows this. - Same class, right answer. The sorted, sorted-set and sorted-numeric views return 2 for the same calls, and so do single-segment codec iterators. The numeric view's own reload branch handles a field-less segment (
:259-261), so field-less segments are meant to work. - Tests treat the mix as legal. The test framework's
AssertingNumericDocValues.advanceasserts onlytarget > in.docID()(AssertingLeafReader.java:791-801). UpstreamTestIndexedDISIcallsnextDoc()right after a falseadvanceExact()(:595-600). UpstreamTestMultiDocValuesnever mixes the two on one iterator (testRandomAdvance:424-437,testRandomAdvanceExact:439-448run on separate iterators), and its numeric and binary tests put the field in every document, which is why no test caught it. - History.
advance()came from LUCENE-7407 (f7aa200d406, 2016-09-21), when a loaded segment always had a non-null iterator. A month later LUCENE-7462 (9aca4c9d560) addedadvanceExact(), which can leavecurrentValuesnull for a loaded segment.git log -G 'currentValues == null'lists only these two commits; no javadoc,CHANGES.txtorMIGRATE.mdentry, test or discussion restricts the mix. - A committer has seen the null state, in a different, illegal call: in the 2020 review of apache/lucene-solr#1733 (LUCENE-9450), mikemccand traced an NPE from
binaryValue()tocurrentValuesbeing null "since.advanceExactwas not yet called" (a value accessor on an unpositioned iterator, misuse) and suggested an assert. Not a report of this bug.
The devil's-advocate check138 words
The strongest case for "intended" (the refute-lens cross-model reviewer and the devil's advocate): MultiDocValues is @lucene.experimental and @lucene.internal, its javadoc says "This is very costly" and steers users to per-leaf access, and no Lucene caller mixes the two calls, so the sequence is out of contract or not worth fixing.
Why it fails: @lucene.internal is an API-stability label ("might change in incompatible ways in the next release", RenderJavadocPlugin.java:506), not permission to throw on a sequence the returned type's own contract allows. The same iterators also reach the public experimental OneMerge#reorder hook as ordinary NumericDocValues / BinaryDocValues of a CodecReader, with nothing that warns the hook's author. The tag lowers priority, not validity. Verdict: not intended; a low-priority robustness bug. Residual risk: a maintainer may answer "internal API, use per-leaf access"; the reorder path answers that.
Recommended A. Guard the same-segment path in advance()
Add one branch after the reload block of each advance(): if the segment was loaded by advanceExact and has no values (currentValues == null), return nextDoc(), which continues in the next segment. Three lines per view; the repro then passes with assertions on and off, and 18 related core test classes, the suggest module and the core static checks pass.
if (readerIndex >= nextLeaf) {
...
if (currentValues == null) {
return nextDoc();
}
+ } else if (currentValues == null) {
+ // positioned by advanceExact on a leaf that has no values for this field
+ return nextDoc();
}
int newDocID = currentValues.advance(targetDocID - currentLeaf.docBase);All options and trade-offs3 options ยท 522 words
Fix A and fix B were run through gradle in a scratch worktree of 645d4fe14a20. A is recommended: the smallest, most direct change, and the one every checker ran. With the fix for the sibling bug LFV-142 as well, the hunt's exhaustive harness reports no mismatches at all.
In advance() of the numeric (:250-263), binary (:383-396) and norms (:117-130) views. Numeric shown; the other two are identical.
if (readerIndex >= nextLeaf) {
...
if (currentValues == null) {
return nextDoc();
}
+ } else if (currentValues == null) {
+ // positioned by advanceExact on a leaf that has no values for this field
+ return nextDoc();
}
int newDocID = currentValues.advance(targetDocID - currentLeaf.docBase);Trade-offs
- Correct by construction:
currentValues == nullwith a target in the current segment meansadvanceExactfound no values there, so the next document with a value is in a later segment, wherenextDoc()starts (nextLeaf). - Covers
advance(NO_MORE_DOCS)on a field-less last segment too (nextDoc()returnsNO_MORE_DOCS). - No change for any call sequence that works today; one null check per
advance()call.
What the checks validated
- Gradle in a scratch worktree of
645d4fe14a20(2026-11-01): the repro fails on stock main and passes with A, each with-Ptests.asserts=trueand=false. - With A: 18 core test classes, 273 tests, 0 failures, 5 skipped (all
@Nightly): the repro,TestMultiDocValues,TestNorms,TestCustomNorms,TestDocValuesIndexing,TestSegmentReader,TestDirectoryReader,TestIndexSorting,TestNumericDocValuesUpdates,TestBinaryDocValuesUpdates,TestDemoParallelLeafReader,TestMaxTermFrequency,TestUniqueTermCount,TestIndexingSequenceNumbers,TestSimilarityProvider(the core tests that read these views),TestSortingCodecReader,TestMergePolicyandTestAddIndexes. - With A: the whole suggest module (the in-tree production caller of these views), 31 classes, 236 tests, 0 failures, 7 skipped;
:lucene:core:check -x testpasses with A and the test in place. The full core suite was not run. - Earlier, the same fix: the hunt's 5-test class 5/0 and the public
OneMerge#reordertest 1/0; an independent 7-test class 7/0; upstreamTestMultiDocValues7/7; in the reorder probe the merge completes and the writer stays open.
An independent verifier's alternative: if (readerIndex >= nextLeaf || currentValues == null) { in each advance().
- if (readerIndex >= nextLeaf) {
+ if (readerIndex >= nextLeaf || currentValues == null) {Trade-offs
One condition per view, but it asks the segment for its doc values again (one extra getNumericDocValues / getBinaryDocValues / getNormValues call), which again returns null and falls into the existing return nextDoc(). Reads less directly than A.
What the checks validated
Gradle, same worktree: the repro passes with B, with assertions on and off; the independent verifier's 7-test class passes 7/0.
Use DocValues.emptyNumeric() / DocValues.emptyBinary() for a field-less segment, like the sorted views (:463-473).
Trade-offs
Removes the null state from all methods at once, but touches nextDoc(), advance() and advanceExact() of three views: a larger diff. Not tested.
Downstream code that reads these merged iterators itself, such as a custom OneMerge#reorder, gets a loud exception on this call order, never wrong data; until a fix ships it should avoid the mix or catch the exception, because an uncaught one inside reorder closes the IndexWriter.
Semi-formal: a bounded-exhaustive harness compared every MultiDocValues view with the per-segment iterators placed end to end, over every legal sequence of up to 3 calls on 1-3 segments (70,662 sequences for each of six views). The numeric, binary and norms views threw an NPE; the smallest case was two calls on two segments. Independent reproducers then confirmed it.
The method in full198 words
Method: semi-formal (bounded-exhaustive translation validation). A hunt unit auditing the callers of ReaderUtil.subIndex wrote a harness that builds every small index layout (which documents of which segment have a value) and checks each MultiDocValues view, on the stock jar, against the concatenation of the segments' own iterators, over every legal nextDoc / advance / advanceExact sequence: length up to 3 on 1-3 segments (258 layouts, 70,662 sequences per view, 423,972 sequence checks over the six views), and length up to 4 on up to 2 segments (42 layouts, 10,790 sequences per view, 64,740 checks).
It reported a NullPointerException bucket for the numeric, binary and norms views (827 sequences each in the run of length up to 3) with the minimal witness: segments [(00)(1)], calls [advanceExact(0), advance(1)]. The sorted, sorted-set and sorted-numeric views had 0 mismatches. A run past maxDoc added the advance(NO_MORE_DOCS) case. The same harness found the sibling norms bug LFV-142; with both fixes it reports no mismatches.
The finding was turned into JUnit tests (direct and through OneMerge#reorder), reviewed by a reviewer from another model family (OpenAI GPT-5.6) in three sessions, reproduced independently, judged, and cut to the one-test repro on 645d4fe14a20.
No prior report found in GitHub issues, PRs and commits (including all 413 open PRs), the JIRA archive, the Lucene and Solr mailing lists, downstream repositories or the web (2026-10-26); a refresh on 2026-11-01 found nothing new. Not fixed upstream as of 2026-11-01.
Searches4 searches
Closest items, none a report of this bug: apache/lucene-solr#1733 / #10490 (LUCENE-9450, 2020: a reviewer saw currentValues null behind an NPE from an illegal binaryValue() call before positioning); #8580 (LUCENE-7529, advanceExact argument check, fixed 2016); #8514 (LUCENE-7462, introducing) and #8460 (LUCENE-7407); #9186 (LUCENE-8138); #9565 (LUCENE-8519); #7232 (LUCENE-6170, open, a sorted-view defect); #13798; #12859 (an analogous null fix for points in the reorder view, 9.9); #12622 (added OneMerge#reorder). Open PRs that edit MultiDocValues.java keep the defect: #14475 (moves the code verbatim), #16847, #13948. Limits: GitHub search does not index inline review comments; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; all of GitHub | 2026-10-26 | scripted prior-report searches (two runs, one ASF-only) and 56 GraphQL searches on apache/lucene, apache/solr, Elasticsearch, OpenSearch and all of GitHub, including stack-trace phrasings such as MultiDocValues$2.advance; the review comments of the related PRs | no report |
| Open PRs | 2026-10-26 | changed files of all 413 open apache/lucene PRs | #14475, #16847 and #13948 edit MultiDocValues.java; all keep the defect |
| JIRA, mailing lists, web | 2026-10-26 | ASF JIRA REST on LUCENE and SOLR; dev@, java-user@, issues@ and commits@ (Lucene) and the Solr lists through lists.apache.org; web queries | only notification mirrors of the related items and how-to threads |
| Refresh and upstream branches | 2026-11-01 | apache/lucene issues and PRs updated since 2026-10-26 mentioning MultiDocValues, advanceExact, currentValues or OneMerge reorder; commits touching the file on main and branch_10_6 | only #16571 (unrelated; does not touch the file); the file last changed 2023-10-11 on both |
Introduced in 7.0.0 by LUCENE-7462 (9aca4c9d560, 2016), which added advanceExact to advance() code written a month earlier in LUCENE-7407; never changed since. Every release from 7.0.0 to 10.6.0 and main is affected (executed on 8 releases and 10.6.0); 6.x has no iterator API; the public reorder path exists from 9.9.0.
Versions tested6 rows
Introduced by 9aca4c9d560 "LUCENE-7462: Give doc values APIs an advanceExact method." (by jpountz; authored 2016-10-20, committed 2016-10-24; JIRA LUCENE-7462, migrated as #8514, JIRA patch workflow, no GitHub PR). Contributing: f7aa200d406 (LUCENE-7407, #8460, by mikemccand, 2016-09-21) wrote the advance() methods with the unguarded same-segment path, which was safe until advanceExact existed. Later edits to these methods (325b74e0e3e, LUCENE-7529, the advanceExact argument check, 2016; the 2020 reformatting 2d6ad2fee6d) do not touch the failing path. First release: 7.0.0 (released 2017-09-20; tagged 2017-09-18). The bracketing is release-level; no commit-level build was done.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (gradle and standalone); passes with fix A or B |
| 10.6.0 | affected | minimal repro fails with assertions on and off (release jars) |
7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2; main ea57360912ed, a9779ef3c193 | first affected 7.0.0 | numeric, binary and norms NPE; both controls pass |
9.9.0, 9.12.3, 10.5.2, main (public OneMerge#reorder path) | affected | numeric and binary NPE inside reorder (9.9.0: the two-argument reorder signature, before 9.12.0 added Executor); 9.8.0 has no reorder hook |
| 6.2.1, 6.6.6 | no API | the repro does not compile: doc values had no iterator API |
upstream main 2f424fd428da, branch_10_6 | read | the same code; MultiDocValues.java last changed 2023-10-11 |
Confirmed with high confidence: the finder, an independent reproducer, an intended-behaviour devil's advocate, an adjudication and three review sessions by a reviewer from another model family (OpenAI GPT-5.6) all reproduced it. The only "not real" verdict was on scope alone (@lucene.internal), from a session that agreed with the root cause.
Checks and verdicts340 words
Everyone agrees on the root cause and the fix, and that the impact is minor: a loud exception, no wrong value, no Lucene caller.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Harness NPE buckets for numeric, binary and norms; JUnit 5 run / 3 failed on main (assertions on and off) and on 7.0.0-10.5.2, both controls passing; the public reorder test fails 1/1; both pass with the fix. |
| cross-model review, refute lens (OpenAI GPT-5.6) | not real, high (scope only) | Reproduced the NPE with assertions on and off and a second seed, agreed on the root cause, and its fix check passed upstream TestMultiDocValues 7/7; called it out of contract because MultiDocValues is @lucene.internal. It did not test the public reorder path. |
| cross-model review, intended lens (OpenAI GPT-5.6) | real, high | Own direct suite 5 run / 3 failed; own public OneMerge#reorder test failed at :263; the intended argument is weak. |
| independent reproducer | real | Own JUnit written from the claim only fails 7 run / 4 failed (assertions on and off), including a layout with the field-less segment in the middle; 3 controls pass; same on 10.5.2, 9.12.3 and 8.11.4; its alternative fix (B) passes 7/0. |
| cross-model review, root-cause lens (OpenAI GPT-5.6) | real, high | Own suite fails 4/5, including advance(NO_MORE_DOCS) on a field-less last segment; own public reorder test 1/1; both pass with the fix. |
| intended-behaviour devil's advocate | real, high | Built the strongest intended case and rejected it; re-ran main (assertions on and off), 10.5.2, and the public path on main and 9.12.3. |
| adjudication | confirmed, high | Re-ran the independent repro (7/4, assertions on and off), the public-path test (1/1) and the fix (7/0). |
| security check | not security-sensitive | Executed probe: an uncaught NPE in reorder closes the IndexWriter (main, 10.5.2); with the fix the merge completes. |
| main refresh and minimal repro | still reproduces | Identical per-test results on main a9779ef3c193; the one-test repro fails on 645d4fe14a20 with assertions on and off and passes with fix A. |
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 400 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,316 words
- Registry title
- MultiDocValues numeric, binary and norms views throw NullPointerException from advance() after advanceExact() landed on a segment without the field
- Area
- index / MultiDocValues merged numeric, binary and norms doc-values views (also the merged reader passed to MergePolicy.OneMerge#reorder)
- Priority
- Low, rank 1138: The MultiDocValues numeric, binary and norms views throw an NPE from advance() after advanceExact() lands on a segment without the field; never a wrong value, and only custom code on internal views or a custom reorder reaches it (likely caught during development).
- Impact class
- minor
- Impact note
- Only application code that holds a merged numeric, binary or norms doc-values view, either from MultiDocValues.getNumericValues/getBinaryValues/getNormValues (a class tagged @lucene.experimental and @lucene.internal) or, since 9.9.0, from the merged reader that IndexWriter passes to a custom MergePolicy.OneMerge#reorder (@lucene.experimental), and that calls advance(t) after advanceExact(e) returned false, with e and t in the same segment and that segment without values for the field (or t = NO_MORE_DOCS after advanceExact landed in a last segment without the field); default codec and config, with -ea and -da, ordinary scale (2 segments, 3 docs in the repro); the result is always a loud NullPointerException, never a wrong value, and no Lucene caller mixes the two calls on these views; if the exception escapes a reorder override, IndexWriter treats it as a tragic merge failure and closes itself, and with merge-on-commit (the default) commit() fails and the documents added since the last commit are rolled back, while the committed index stays intact (CheckIndex clean); released 7.0.0 through 10.6.0 (10.6.0 executed: the one-test minimal repro fails on the release jars with -ea and -da, 2026-11-01).
- Summary
- You read a merged doc-values view over an index with several segments (MultiDocValues.getNumericValues, getBinaryValues or getNormValues, or the merged reader that IndexWriter hands to a custom OneMerge#reorder), call advanceExact(0), which returns false because doc 0's segment has no values for the field, and then call advance(1). You expect advance(1) to return the next document that has a value (doc 2 in the repro), as the sorted-numeric view of the same index and the per-segment codec iterators do. Instead it throws NullPointerException ('this.currentValues is null'): advanceExact stored a null per-segment iterator for the segment without the field, and advance() assumes that iterator is non-null as long as the target stays in the same segment.
- Introduced
- 9aca4c9d560 (9aca4c9d56089a9ac89df5fd93be76a4fe822448) 'LUCENE-7462: Give doc values APIs an advanceExact method.' by jpountz (JIRA LUCENE-7462, migrated as apache/lucene#8514; JIRA patch workflow, no GitHub PR): its advanceExact() in the numeric, binary and norms views stores currentValues = null for a segment without the field and sets nextLeaf past that segment; contributing commit f7aa200d406 (LUCENE-7407, apache/lucene#8460, mikemccand) wrote the advance() methods whose same-segment path calls currentValues.advance(...) with no null check, which was safe until advanceExact existed [read: git blame -w -M -C and git log -G 'currentValues == null' on MultiDocValues.java list only these two commits; later edits (325b74e0e3e LUCENE-7529 argument check, 2d6ad2fee6d reformatting) do not touch the failing path; exec: release-level bracketing only (6.2.1 and 6.6.6 have no iterator API, 7.0.0 fails); no commit-level build] (2016-10-20 (author date of 9aca4c9d560; committed to master 2016-10-24); contributing f7aa200d406 2016-09-21)
- First affected version
- 7.0.0 [exec: fails 5 run / 3 failed; 6.2.1 and 6.6.6 do not compile the repro because doc values had no iterator API, only get(int)]; public OneMerge#reorder path from 9.9.0 [exec; 9.8.0 has no reorder hook]
- Affected versions
- every release 7.0.0 through 10.6.0 (7.x, 8.x, 9.x, 10.x; 10.6.0 [exec: the one-test minimal repro, SHA-1 checked against Maven Central, -ea and -da, 2026-11-01]) and main ea57360912ed; never fixed. Executed failing: 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea), main (-da) [exec, finder and the version-history runs]; independently re-run: main -ea/-da, 10.5.2, 9.12.3 (-ea and -da) and 8.11.4 by the independent reproduction, main -ea/-da and 10.5.2 by the intended-behaviour check, main by the adjudication [exec]; other releases by unchanged code (blame, git log -G) [read]. Public OneMerge#reorder path: 9.9.0, 9.12.3, 10.5.2 and main (-ea and -da) fail [exec]; absent in 9.8.0 [exec: compile error]. Not affected: 6.x and older (no doc-values iterator API) [exec]. Upstream main, branch_10x, branch_10_6 and branch_9_12 still have the code [read, 2026-10-26]; open PR #14475 moves the code verbatim and keeps the defect [read].
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check
- Upstream and prior reports
- none found (2026-10-26, read-only: the finder's scripted prior-report search (16 GitHub issue/PR, 2 commit, 2 list and 3 JIRA-archive queries, 251 hits, none the same bug); a second search: a scripted prior-report search over GitHub, commits, dev@/java-user@/issues@/commits@ lists, the JIRA archive and the web (about 50 queries) plus a separate ASF-only run (230 hits, 0 high or medium overlap), 56 GitHub GraphQL searches on apache/lucene, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and all of GitHub, including stack-trace phrasings such as "MultiDocValues$2.advance", the changed-file lists of all 413 open apache/lucene PRs, ASF JIRA REST queries on LUCENE and SOLR, direct lists.apache.org searches, and the full git history; the independent reproduction's and the intended-behaviour check's own GitHub searches): no issue, PR, JIRA ticket, list post, downstream report or web page reports this NPE, and nothing upstream fixes it. Related, not this bug: apache/lucene-solr#1733 / #10490 (LUCENE-9450, 2020: a reviewer noticed that MultiDocValues leaves currentValues null, which caused an NPE from binaryValue() called before advanceExact, an illegal call, and suggested an assert; different trigger, method and fix), #8580 (LUCENE-7529, advanceExact argument check, fixed 2016), #8514 (LUCENE-7462, introducing), #8460 (LUCENE-7407), #9186 (LUCENE-8138), #9565 (LUCENE-8519), #7232 (LUCENE-6170, open, a different sorted-view defect), #13798, #12859 (an analogous null-handling fix for points in SlowCompositeCodecReaderWrapper, 9.9), #12622 (added OneMerge#reorder). Open PRs touching MultiDocValues.java keep the defect: #14475 (refactor, moves the code verbatim), #16847 (int casts and a guard only), #13948 (binary value accessor only). Limits: GitHub search does not index inline review comments; the scripted search's GitHub source ran 8 of its 40 planned queries, and the GraphQL searches covered the rest
- Lucene commit tested
- main
645d4fe14a20(the minimal repro through gradle with-Ptests.asserts=trueand=false, stock and with fix A and fix B; fix A with the closest core suites, the suggest module and:lucene:core:check -x test; standalone runs with assertions on and off and a second seed), the 10.6.0 release jars (SHA-1 checked; minimal repro, assertions on and off), and, by the hunt and its verifiers, mainea57360912edanda9779ef3c193and the releases 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.9.0, 9.12.3, 10.0.0 and 10.5.2 (6.2.1, 6.6.6 and, for the reorder path, 9.8.0 do not compile: the API is absent). Every cited Lucene file is byte-identical atea57360912edand645d4fe14a20;MultiDocValues.javais unchanged on upstream main2f424fd428da(2026-10-30, read).
- Finding record
lucene/core/src/java/org/apache/lucene/index/MultiDocValues.java:108-163 (at ea57360912ed) (norms), :241-298 (numeric), :374-431 (binary), :33-36 (@lucene.experimental, @lucene.internal); lucene/core/src/java/org/apache/lucene/index/DocValuesIterator.java:24-33 (advanceExact contract); lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:141-150 (advance contract); lucene/core/src/java/org/apache/lucene/index/SlowCompositeCodecReaderWrapper.java:291, 412, 417; lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:311-324 (OneMerge#reorder); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3561-3562, 5557-5558, 5030-5058, 5109-5113; lucene/test-framework/src/java/org/apache/lucene/tests/index/AssertingLeafReader.java:791-801 (AssertingNumericDocValues.advance asserts only target > in.docID())
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/MultiDocValues.java:33-36, :108-163, :241-296, :374-429, :463-473 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/ReaderUtil.java:74-94 (at 645d4fe14a20)
- Who is affected
lucene/core/src/java/org/apache/lucene/index/SlowCompositeCodecReaderWrapper.java:291, :412, :417; IndexWriter.java:3561, :5557, :5030-5058, :5109-5113; MergePolicy.java:311-327, :689-714; IndexWriterConfig.java:108 (at 645d4fe14a20)lucene/suggest/src/java/org/apache/lucene/search/suggest/DocumentDictionary.java:133, :252; analyzing/AnalyzingInfixSuggester.java:779, :788; analyzing/BlendedInfixSuggester.java:234, :245; lucene/luke/src/java/org/apache/lucene/luke/models/documents/DocumentField.java:81; luke/models/util/IndexUtils.java:482, :498 (at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/DocValuesIterator.java:24-33; lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:137-163 (at 645d4fe14a20)lucene/test-framework/src/java/org/apache/lucene/tests/index/AssertingLeafReader.java:791-801; lucene/core/src/test/org/apache/lucene/codecs/lucene90/TestIndexedDISI.java:595-600; lucene/core/src/test/org/apache/lucene/index/TestMultiDocValues.java:424-448 (at 645d4fe14a20)build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/RenderJavadocPlugin.java:506 (at 645d4fe14a20)
- TestMultiDocValuesAdvanceAfterAdvanceExact.java (JUnit, one test):
repro/LFV-136/src/org/apache/lucene/index/TestMultiDocValuesAdvanceAfterAdvanceExact.java(51 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.
assets/LFV-136/bug.png(400 KB)
