All findings · Lucene finding · LFV-136 · multidocvalues-advance-after-advanceexact-npe

MultiDocValues numeric, binary and norms views throw NullPointerException from advance() after advanceExact() landed on a segment without the field

On the merged numeric, binary and norms doc-values iterators of MultiDocValues, advance() after an advanceExact() that returned false in a segment without the field throws NullPointerException instead of returning the next document with a value. A custom OneMerge#reorder gets the same iterators; 7.0.0 through 10.6.0 are affected.

Summary

Priority
Low, rank 1138The 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
minor
Since
7.0.0 (LUCENE-7462, commit 9aca4c9d560, 2016)introduced 2016-10-20 · over 10 years
Affected
7.0.0-10.6.0 and main
Status
documented
Found by
semi-formal: bounded-exhaustive translation validation

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Two calls on one merged iterator are enough, on an index where one segment lacks the field.

Background: merged doc-values iterators

  1. 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).
  2. MultiDocValues.getNumericValues(reader, field) stitches the segments' iterators into one iterator over the whole index. IndexWriter hands the same stitched iterators to a custom MergePolicy.OneMerge#reorder during a merge.
  3. Besides nextDoc(), a doc-values iterator can jump forward in two ways: advance(t) jumps to the first document at or after t that has a value; advanceExact(t) moves to exactly t and returns whether t has a value. Both are legal on one iterator, as long as targets never go backwards.
Diagram of the bug. An index with two segments: segment 0 holds docs 0 and 1 without the field f, segment 1 holds doc 2 with f = 42. One merged iterator from MultiDocValues.getNumericValues runs over both. advanceExact(0) loads segment 0, gets no per-segment iterator (null), marks segment 0 as loaded and returns false. advance(1) targets a document in the same segment, so it skips the reload and calls currentValues.advance on null: NullPointerException at MultiDocValues.java:263. Expected: advance(1) returns 2, as nextDoc() and the sorted-numeric view do.
Two segments and one merged iterator: 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.

The finding, step by step

  1. Index two segments: segment 0 holds docs 0 and 1 without the numeric doc-values field f, segment 1 holds doc 2 with f = 42. Open one merged iterator over both: MultiDocValues.getNumericValues(reader, "f").
  2. Call advanceExact(0). It returns false, correctly: doc 0 has no value. The iterator now sits on doc 0.
  3. 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.
  4. Instead it throws NullPointerException: ... because "this.currentValues" is null. advanceExact recorded "segment 0 has no values" as a null per-segment iterator, and advance uses that iterator without a check while the target stays in segment 0.
  5. 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.

Who is affected

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.

Terms

  • Merged view: the anonymous NumericDocValues / BinaryDocValues classes returned by MultiDocValues.getNumericValues, getBinaryValues and getNormValues when a reader has two or more segments. Each keeps currentLeaf, currentValues (the current segment's own iterator, or null) and nextLeaf (the next segment to load).
  • Field-less segment: a segment in which no document has the field, so LeafReader#getNumericDocValues(field) returns null. Normal for optional or sparse fields, or a field added to the schema later.

The bug

  1. 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 is null for a field-less segment, and nextLeaf = readerIndex + 1 (:289). It sets docID = targetDocID (:291) and returns false (:292-293).
  2. 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 that advanceExact just loaded, readerIndex < nextLeaf, so it skips the branch and calls currentValues.advance(targetDocID - currentLeaf.docBase) (:263) on null.
  3. ReaderUtil.subIndex never returns leaves.size() (ReaderUtil.java:74-94): a target >= maxDoc maps to the last segment. So after advanceExact lands in a field-less last segment, advance(NO_MORE_DOCS) throws too; the readerIndex == leaves.size() check at :251 cannot fire.
  4. 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.
  5. 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 their currentValues is never null for a loaded segment, and they return the right document for the same calls.

The numbers

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) after advanceExact(e) returned false, with e and t in a segment without the field (or t >= maxDoc after landing in a field-less last segment). Two entry points: MultiDocValues.getNumericValues / getBinaryValues / getNormValues (public class, tagged @lucene.experimental and @lucene.internal), and, since 9.9.0, the merged CodecReader that IndexWriter passes to a custom MergePolicy.OneMerge#reorder (@lucene.experimental), whose norms, numeric and binary doc values are these views (SlowCompositeCodecReaderWrapper.java:291, :412, :417).
  • No Lucene caller is affected. DocumentDictionary uses advance only; the infix suggesters open a fresh view per hit; Luke uses advanceExact only; 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 NullPointerException at the first such advance(), with assertions on and off. No wrong value, no hang, nothing written.
  • Inside reorder it closes the writer (executed on main and 10.5.2). If the exception escapes a reorder override, IndexWriter treats 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).

Minimal repro

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.

TestMultiDocValuesAdvanceAfterAdvanceExact.java (JUnit, one test)51 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.index;

import 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);
      }
    }
  }
}
Command
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, as nextDoc() 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 null at MultiDocValues.java:263, with assertions on and off.

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

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.

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
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)

Observed output

# 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

Why it looks unintended

  • The contract allows it: after advanceExact(0), docID() is 0, and advance(t) is defined for any t > docID().
  • The sorted views of the same class and the per-segment codec iterators return 2 for the same calls.
  • advance() was written before advanceExact() 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() returns target"; the only calls it declares illegal after a false return are intoBitSet and docIDRunEnd (:30-31). DocIdSetIterator#advance (DocIdSetIterator.java:137-163) is undefined only for target <= current or after exhaustion; for target > current it "behaves as if written" as a nextDoc() loop (:144-153), and it "may be called with NO_MORE_DOCS" (:157-159). The repro obeys both. Nothing in MultiDocValues' 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.advance asserts only target > in.docID() (AssertingLeafReader.java:791-801). Upstream TestIndexedDISI calls nextDoc() right after a false advanceExact() (:595-600). Upstream TestMultiDocValues never mixes the two on one iterator (testRandomAdvance :424-437, testRandomAdvanceExact :439-448 run 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) added advanceExact(), which can leave currentValues null for a loaded segment. git log -G 'currentValues == null' lists only these two commits; no javadoc, CHANGES.txt or MIGRATE.md entry, 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() to currentValues being null "since .advanceExact was 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.

Suggested fix

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.

A. Guard the same-segment path in advance() (validated by the checks) (recommended)

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 == null with a target in the current segment means advanceExact found no values there, so the next document with a value is in a later segment, where nextDoc() starts (nextLeaf).
  • Covers advance(NO_MORE_DOCS) on a field-less last segment too (nextDoc() returns NO_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=true and =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, TestMergePolicy and TestAddIndexes.
  • 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 test passes 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#reorder test 1/0; an independent 7-test class 7/0; upstream TestMultiDocValues 7/7; in the reorder probe the merge completes and the writer stays open.

B. Take the reload branch also when currentValues is null (validated by the checks)

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.

C. Never store null: substitute an empty iterator, as the sorted views do

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 note

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.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; all of GitHub2026-10-26scripted 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 PRsno report
Open PRs2026-10-26changed files of all 413 open apache/lucene PRs#14475, #16847 and #13948 edit MultiDocValues.java; all keep the defect
JIRA, mailing lists, web2026-10-26ASF JIRA REST on LUCENE and SOLR; dev@, java-user@, issues@ and commits@ (Lucene) and the Solr lists through lists.apache.org; web queriesonly notification mirrors of the related items and how-to threads
Refresh and upstream branches2026-11-01apache/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_6only #16571 (unrelated; does not touch the file); the file last changed 2023-10-11 on both

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (gradle and standalone); passes with fix A or B
10.6.0 affectedminimal 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.0numeric, binary and norms NPE; both controls pass
9.9.0, 9.12.3, 10.5.2, main (public OneMerge#reorder path) affectednumeric 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 APIthe repro does not compile: doc values had no iterator API
upstream main 2f424fd428da, branch_10_6 readthe same code; MultiDocValues.java last changed 2023-10-11

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateHarness 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, highOwn direct suite 5 run / 3 failed; own public OneMerge#reorder test failed at :263; the intended argument is weak.
independent reproducer realOwn 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, highOwn 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, highBuilt 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, highRe-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-sensitiveExecuted 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 reproducesIdentical per-test results on main a9779ef3c193; the one-test repro fails on 645d4fe14a20 with assertions on and off and passes with fix A.

Diagram file

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.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,316 words

Finding record

LFV-136, snapshot of 2026-11-06

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=true and =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, main ea57360912ed and a9779ef3c193 and 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 at ea57360912ed and 645d4fe14a20; MultiDocValues.java is unchanged on upstream main 2f424fd428da (2026-10-30, read).

Lucene and upstream sources

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)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.