All findings · Lucene finding · LFV-606 · matchhighlight-fieldvalueprovider-null-values-npe

MatchRegionRetriever.highlightDocument throws NullPointerException when a FieldValueProvider returns the null its javadoc allows

FieldValueProvider.getValues may return null "if the field is not loaded or does not exist", but when a custom provider does so for a matched field, MatchRegionRetriever.highlightDocument throws NullPointerException instead of returning no highlights. Every release from 10.0.0 to 10.6.0 and main have it; returning an empty list works.

Summary

Priority
Low, rank 658MatchRegionRetriever throws an NPE when a hand-written FieldValueProvider returns null as its javadoc allows, a loud crash reachable only through the expert highlightDocument path with a custom provider.
Impact
crash
Since
10.0.0 (#12881, 2023-12-07), which documented null as allowedintroduced 2023-12-07 · about 2 years
Affected
every release from 10.0.0 to 10.6.0, and main
Status
documented
Found by
code reading of the FieldValueProvider contract, then a public-API JUnit

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

The default offset strategies use the list getValues returns without checking for null. In-tree callers are safe only because the built-in provider never returns null.

Background: how matchhighlight finds hit regions

  1. MatchRegionRetriever computes, for a document the query matched, the character ranges ("hit regions") to highlight in each matched field.
  2. When the index has no offsets for a field, or a match does not report them, it re-analyzes the field's text to turn match positions into character offsets. It gets that text from a FieldValueProvider.
  3. highlightDocuments(...) uses a built-in provider over the stored fields; the low-level highlightDocument(...) takes your own provider.
LFV-606 summary: one document with the unstored field body = "foo bar" matches the query body:foo. A custom FieldValueProvider returns null for body, as its javadoc allows. Expected: highlightDocument returns no hit regions, as it does for an empty list and on the built-in highlightDocuments path. Actual on main: NullPointerException at OffsetsFromPositions.java:70, because the default offset strategies use the list without a null check.
The minimal repro step by step: one document whose unstored field body matches body:foo, a provider that returns null, and the expected empty result against the NPE at OffsetsFromPositions.java:70. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index one document whose field body = "foo bar" is indexed with positions but not stored (TextField, Field.Store.NO). The query body:foo matches it.
  2. Call the low-level highlightDocument(...) with your own FieldValueProvider. body is not in the stored document, so getValues("body") returns null, as its javadoc allows: "null if the field is not loaded or does not exist".
  3. You expect no hit regions (an empty result map). On main, that is what the same call returns when the provider gives an empty list, and what the built-in highlightDocuments(...) path returns for this index.
  4. Instead it throws NullPointerException ("values" is null) at OffsetsFromPositions.java:70, with assertions on and off: the default offset strategies call values.isEmpty() or values.size() on it without a null check.
  5. Only callers with their own provider are hit: MatchHighlighter and highlightDocuments(...) use a built-in provider that returns an empty list. Workaround: return List.of().

Who is affected

Applications that call the low-level highlightDocument(...) with their own FieldValueProvider and return null for a matched field whose offsets must be recomputed from its text: a field indexed without offsets, or one indexed with offsets when a match carries none (for example Intervals.extend). An opt-in expert API: MatchHighlighter and highlightDocuments(...) are not affected. One clean exception per call.

Mechanism, terms and numbers678 words

MatchRegionRetriever.highlightDocument is a public low-level method that "can be used with custom document FieldValueProvider" (MatchRegionRetriever.java:390-393 at 645d4fe14a20). FieldValueProvider#getValues documents that it returns the field's values "or null if the field is not loaded or does not exist for the field" (:100-104). When a custom provider returns that null for a field the query matched, the default offset strategies use it without a check: values.isEmpty() at OffsetsFromPositions.java:70 for a positions field, values.size() at OffsetsFromTokens.java:74 for a DOCS or DOCS_AND_FREQS field, and, for a field indexed with offsets, the OffsetsFromMatchIterator.java:66 fallback whenever a match carries no offsets (for example an Intervals.extend query). The public, non-default OffsetsFromValues loops over it at OffsetsFromValues.java:51. The call throws NullPointerException instead of returning no hit regions; on main, no hit regions is what an empty list gives on the same index and what the built-in highlightDocuments(...) path gives, because its private provider maps a missing field to List.of() (:535-538). The null clause came with #12881, so every release from 10.0.0 to 10.6.0 and main are affected. In the cases run: no wrong results, no corruption, no hang, one clean exception for that call.

The mechanism (main 645d4fe14a20, lucene/highlighter/.../search/matchhighlight/)

  1. The contract. FieldValueProvider is a public interface: "@return Return a list of values for the provided field name or {@code null} if the field is not loaded or does not exist for the field." (MatchRegionRetriever.java:99-105).
  2. The entry point. highlightDocument(LeafReaderContext, int, FieldValueProvider doc, ToIntFunction<String>, Map<String, List<OffsetRange>>) is public, documented as usable with a custom provider, and states no non-null precondition (:390-400). For each query-affected highlighted field with matches (:410-418) it calls offsetStrategy.get(matchesIterator, doc) (:434) and stores non-empty ranges (:435-437).
  3. The default strategy per field (:488-505): positions without offsets use OffsetsFromPositions (:492-493); positions with offsets use OffsetsFromMatchIterator with an OffsetsFromPositions fallback (:489-490); DOCS, DOCS_AND_FREQS and DOCS_AND_CUSTOM_FREQS use OffsetsFromTokens (:495-505).
  4. No strategy checks for null:
    • OffsetsFromPositions.java:59 passes doc.getValues(field) on, and :69-72 does
// If values are not stored, there is nothing to highlight...
if (values.isEmpty()) {        // :70  NullPointerException when values == null
  return List.of();
}
  • OffsetsFromTokens.java:54 reads the list and :74 calls values.size().
  • OffsetsFromValues.java:47 reads it and :51 loops over it (a public strategy a caller may choose; never a default).
  • OffsetsFromMatchIterator.java:64-66 falls back to OffsetsFromPositions with doc.getValues(field) as soon as one match reports a negative offset (:52-57). That happens on a field indexed with offsets too: matches of Intervals.extend always report offset -1 (ExtendedIntervalsSource.java:89-101 in lucene/queries), and Lucene's own testDegenerateIntervalsWithOffsets (TestMatchRegionRetriever.java:393-415) uses this fallback with the built-in provider.
  1. Why in-tree callers never see it. The only in-tree provider, the private StoredFieldsVisitor.getValues (:535-538), returns values == null ? List.of() : values, and the only in-tree call of highlightDocument (:372) passes it. No Lucene test implements FieldValueProvider or calls highlightDocument directly.

The numbers (executed)

same index, query body:foo, unstored positions field bodyresult
built-in highlightDocuments(...)no hit regions
highlightDocument(...), provider returns List.of()no hit regions
highlightDocument(...), provider returns null (main)NullPointerException at OffsetsFromPositions.java:70
the same on 10.0.0 to 10.6.0NullPointerException at OffsetsFromPositions.java:96 (values.size())
a DOCS_AND_FREQS field, null (main, 10.0.0, 10.6.0)NullPointerException at OffsetsFromTokens.java:74; List.of() gives no regions
with the null-as-empty fix (main)no hit regions

On a field indexed with offsets (DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS, unstored, "foo bar"; default strategy OffsetsFromMatchIterator; main, -ea and -da):

query, providerresult
body:foo (term query), nullhit regions from the index; the values are never read
IntervalQuery(Intervals.extend(Intervals.term("bar"), 1, 0)), List.of()no hit regions
the same query, nullNullPointerException at OffsetsFromPositions.java:70 <- OffsetsFromMatchIterator.java:66
the same query, null, with the null-as-empty fix (-da)no hit regions

On releases, a positions field also throws for an empty list (RuntimeException: One of the offsets missing for position range, OffsetsFromPositions.java:146 on 10.6.0); main removed that with #15577, which is in no release. So on releases the null-specific failure is the DOCS / DOCS_AND_FREQS one.

Exposure and likelihood229 words
  • Who is hit. Callers of the public highlightDocument(...) with a hand-written FieldValueProvider that returns null for a highlighted field the query matched, when the field's offsets have to be recomputed from its text: a field indexed without offsets (positions-only, DOCS, DOCS_AND_FREQS), a field indexed with offsets when a match carries no offsets (for example an Intervals.extend query, whose matches report offset -1), or the opt-in OffsetsFromValues. In every such case run, the call throws.
  • Default vs opt-in. Default strategies and settings, no special codec, no scale; opt-in on the API side. MatchHighlighter and highlightDocuments(...) use the built-in provider and are not affected. On a field indexed with offsets, a query whose matches carry offsets (a term query, for example) never reads the values. The recorded GitHub code searches (default branches) found no use of MatchRegionRetriever in Solr, Elasticsearch or OpenSearch.
  • Effect. An unchecked NullPointerException for that document's highlight call. In the cases run: no wrong regions, no corruption, no hang; 1,000 failing calls left no state behind in the retriever or the shared analyzer. When several fields are highlighted, the caller's output map may already hold the (correct) ranges of fields handled before the failing one, so discard it after a failure. Whether it costs one snippet or a whole result page depends on the application's own error handling.
  • Workaround. Return List.of() instead of null.

Minimal repro

One JUnit test in lucene/highlighter, public API only: one document with an unstored TextField, the query body:foo, and a provider whose getValues returns null. Deterministic, single thread. It fails on main with assertions on and off, and on the 10.0.0 and 10.6.0 release jars.

TestFieldValueProviderNullValues.java (lucene/highlighter test, one method)79 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.search.matchhighlight;

import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestFieldValueProviderNullValues extends LuceneTestCase {

  public void testNullValuesGiveNoHighlights() throws Exception {
    Analyzer analyzer = new StandardAnalyzer();
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(analyzer))) {
      Document doc = new Document();
      doc.add(new TextField("body", "foo bar", Field.Store.NO));
      w.addDocument(doc);
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        MatchRegionRetriever retriever =
            new MatchRegionRetriever(
                new IndexSearcher(reader),
                new TermQuery(new Term("body", "foo")),
                analyzer,
                _ -> false,
                _ -> true);
        MatchRegionRetriever.FieldValueProvider noValues =
            new MatchRegionRetriever.FieldValueProvider() {
              @Override
              public List<String> getValues(String field) {
                return null; // allowed: "null if the field is not loaded or does not exist"
              }

              @Override
              public Iterator<String> iterator() {
                return Collections.emptyIterator();
              }
            };
        Map<String, List<OffsetRange>> highlights = new HashMap<>();
        try {
          retriever.highlightDocument(
              reader.leaves().get(0), 0, noValues, _ -> Integer.MAX_VALUE, highlights);
        } catch (NullPointerException e) {
          throw new AssertionError("expected no highlights for null field values, got " + e, e);
        }
        assertEquals(Map.of(), highlights);
      }
    }
  }
}
Command
cp $REPRO/TestFieldValueProviderNullValues.java $LUCENE/lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/
cd $LUCENE && ./gradlew :lucene:highlighter:test --tests org.apache.lucene.search.matchhighlight.TestFieldValueProviderNullValues -Ptests.seed=DEADBEEF
# also with -Ptests.asserts=false
Expected
highlightDocument returns normally and the output map stays empty: no hit regions for a field with no values.
Observed
NullPointerException ("values" is null) at OffsetsFromPositions.java:70 on main (:96 on 10.0.0 to 10.6.0), with assertions on and off.

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

More files, environment, full output293 words

The try/catch only turns the NPE into an AssertionError that states expected vs actual. The only randomness is the test framework's per-seed default codec, locale and time zone (seed DEADBEEF ran with codec=Asserting(Lucene104), locale=brx, timezone=Asia/Tokyo); the result was the same for every seed tried (DEADBEEF, 5EED1234 three times, 7EB4A001, 7EB4A002).

On 10.0.0 to 10.6.0 the test also fails with the NPE (at OffsetsFromPositions.java:96), but there a positions field also throws for an empty list (#15577 is in no release). To show the null-specific failure on a release, use a DOCS_AND_FREQS field: an empty list gives no regions and null throws at OffsetsFromTokens.java:74. Those runs compiled the test with JDK 25 against the release jars; 10.x builds at Java 21, which rejects the _ lambda parameters, so in a 10.x checkout rename them (for example f -> false).

Suggested regression test: this same test, as its own class or as one method in TestMatchRegionRetriever.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.0.0 and 10.6.0 release jars
jdkOpenJDK 25.0.3
osLinux aarch64 (the only platform run; pure Java logic)

Observed output

# main 645d4fe14a20, gradle, seed DEADBEEF, -Ptests.asserts=true (the same with false)
TestFieldValueProviderNullValues > testNullValuesGiveNoHighlights FAILED
    java.lang.AssertionError: expected no highlights for null field values, got java.lang.NullPointerException: Cannot invoke "java.util.List.isEmpty()" because "values" is null
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:A4A4612B6860C9EF]:0)
        at org.apache.lucene.search.matchhighlight.TestFieldValueProviderNullValues.testNullValuesGiveNoHighlights(TestFieldValueProviderNullValues.java:73)

        Caused by:
        java.lang.NullPointerException: Cannot invoke "java.util.List.isEmpty()" because "values" is null
            at org.apache.lucene.search.matchhighlight.OffsetsFromPositions.convertPositionsToOffsets(OffsetsFromPositions.java:70)
            at org.apache.lucene.search.matchhighlight.OffsetsFromPositions.get(OffsetsFromPositions.java:59)
            at org.apache.lucene.search.matchhighlight.MatchRegionRetriever.highlightDocument(MatchRegionRetriever.java:434)
            at org.apache.lucene.search.matchhighlight.TestFieldValueProviderNullValues.testNullValuesGiveNoHighlights(TestFieldValueProviderNullValues.java:70)
:lucene:highlighter:test (FAILURE): 1 test, 1  failure

# 10.6.0 release jar, -da: the same AssertionError, caused by
#   NullPointerException: Cannot invoke "java.util.List.size()" because "values" is null
#   at OffsetsFromPositions.convertPositionsToOffsets(OffsetsFromPositions.java:96) <- get(:59) <- MatchRegionRetriever.highlightDocument(:429)
# with the null-as-empty fix on main: 1 test, 0 failures (assertions on and off)

Why it looks unintended

  • getValues documents null "if the field is not loaded or does not exist"; highlightDocument is documented for custom providers, with no precondition.
  • Main treats missing values as "nothing to highlight" (#15577); an empty list on the same index gives no highlights.
  • #12881 added the null clause without a null check in the strategies; its review never discussed null.
The full argument231 words
  • The javadoc allows null and names the case: "or {@code null} if the field is not loaded or does not exist for the field" (MatchRegionRetriever.java:101-102). An indexed but unstored field is the natural reading of the "does not exist" case (the javadoc does not define it further).
  • The entry point invites custom providers: "Low-level method for retrieving hit ranges for a single document. This method can be used with custom document FieldValueProvider." (:390-393). No precondition, and nothing in the package is marked @lucene.experimental or @lucene.internal.
  • Main defines missing values as "nothing to highlight": #15577 (548ae997d12, 2026-01-15) added "If values are not stored, there is nothing to highlight..." and an early return for an empty list (OffsetsFromPositions.java:69-72). The test expects the same for null.
  • History: #12881 (755de5aae33, 2023-12-07) replaced the 9.x interface List<CharSequence> getValues(String field) throws IOException, which had no null clause and whose default provider never returned null. In the same commit it added the null clause, wrote the new provider to return List.of() for a missing field, and changed the strategies only from List<CharSequence> to List<String>. Its body, reviews and comments never discuss null; MIGRATE.md calls it "Minor API changes", and neither it nor CHANGES.txt mentions null.
  • Tests: no upstream test returns null from a provider or expects an exception for it.
The devil's-advocate check190 words

Strongest case (from the intended-behaviour checker, the cross-model reviewer and the adjudication): a custom provider is expected to supply every query-affected highlighted field, as the constructors' field-loading guidance describes (MatchRegionRetriever.java:118-128 and :153-163), and the FieldValueProvider javadoc itself sends readers to that guidance "on which fields are available through this interface" (:93-98); the null clause is a javadoc slip (its wording, "does not exist for the field", is garbled); the strategies date from 9.x, where null was impossible; and the method is labelled low-level. So the javadoc, not the code, should change.

Why it fails, in part: the cross-reference makes the argument stronger than it first looks, but the guidance only says which fields the predicates load; it does not require non-null values or say what null means, while the getValues javadoc explicitly allows null. And "does not exist" is not a loading question: a provider that loads every stored field and returns null only for a field the document does not store still throws, while the empty-list and built-in controls on the same index give no regions. A javadoc-only fix is still a fair outcome.

Suggested fix

Recommended Treat a null values list as empty in the three strategies

Treat a null list as empty, "nothing to highlight", in OffsetsFromPositions.convertPositionsToOffsets (which also covers the OffsetsFromMatchIterator fallback), OffsetsFromTokens.get and OffsetsFromValues.get (+10/-2 lines in three files). With it the minimal test passes with assertions on and off, and the whole lucene/highlighter suite passes (35 classes, 723 tests, 0 failures).

// OffsetsFromPositions.convertPositionsToOffsets
// If values are not stored (or not loaded), there is nothing to highlight...
if (values == null || values.isEmpty()) {
  return List.of();
}

// OffsetsFromTokens.get and OffsetsFromValues.get
List<String> values = doc.getValues(field);
if (values == null) {
  return List.of();
}
All options and trade-offs3 options · 471 words

The recommended fix was applied with git apply to a scratch worktree of 645d4fe14a20 and run through gradle; the alternatives were not run.

Treat a null values list as empty in the three strategies (validated by the checks) (recommended)

In OffsetsFromPositions.convertPositionsToOffsets change the early return to if (values == null || values.isEmpty()) return List.of();; in OffsetsFromTokens.get and OffsetsFromValues.get return List.of() right after doc.getValues(field) when it is null.

// OffsetsFromPositions.convertPositionsToOffsets
// If values are not stored (or not loaded), there is nothing to highlight...
if (values == null || values.isEmpty()) {
  return List.of();
}

// OffsetsFromTokens.get and OffsetsFromValues.get
List<String> values = doc.getValues(field);
if (values == null) {
  return List.of();
}

Trade-offs

  • Matches the javadoc and the existing "nothing to highlight" early return; non-null lists behave exactly as before, and the check is one comparison per highlighted field.
  • Covers custom callers of the public strategy classes too. A custom OffsetsRetrievalStrategy that calls getValues still has to handle null itself.
  • A 10.x backport needs the same checks; there a positions field also throws for an empty list (fixed on main by #15577), so the backport may want #15577's early return as well.
  • Open PR #16 (2021) adds one more getValues loop in OffsetsFromMatchIterator; it would need the same check if it is ever merged.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20 with the repro test added, seed DEADBEEF: the repro test passes with -Ptests.asserts=true and false.
  • The whole :lucene:highlighter:test with the fix, assertions on: 35 classes, 723 tests, 0 failures. The same suite without the fix: 723 tests, 1 failure, the repro.
  • :lucene:highlighter:checkGoogleJavaFormat passes with the fix.
  • Earlier hunt runs: with the fix shadowed, the minimal test (main, -ea and -da), the independent reproduction's 7-test class (7 of 7) and the intended-behaviour check's unstored-field test (5 of 5) pass; the 48 matchhighlight tests passed with it on a9779ef3c193.

Change the contract: getValues must return an empty list (not run)

Rewrite the getValues javadoc to say that a field with no values (not loaded or absent) returns an empty list, never null.

Trade-offs

No code change and no cost, and it matches what the built-in provider already does. But existing custom providers that followed the current javadoc keep failing with an NPE, and nothing tells them why; a clear exception or the null check would still help them.

What the checks validated

Not run.

Normalise null once in highlightDocument (not run)

Wrap the caller's provider in highlightDocument so that getValues returns List.of() for null, before any strategy sees it.

Trade-offs

One place, and it also protects custom strategies called from highlightDocument. But it does not cover callers that use the public strategy classes with their own provider outside highlightDocument, and it adds a wrapper object per document.

What the checks validated

Not run.

Downstream note

Downstream products are affected only if their own code passes highlightDocument a provider that returns null; the recorded code searches (default branches) found no use of MatchRegionRetriever in Solr, Elasticsearch or OpenSearch.

How it was found

Code reading: while writing a model of the matchhighlight passages path, the finder read the FieldValueProvider contract and saw that the strategies never check the null it allows. The model drives only the built-in path, so no tool produced the signal; a public-API JUnit then showed the NPE.

The method in full89 words

Method: non-formal (code reading, then a directed JUnit), 2026-10-26, main a9779ef3c193. The hunt unit built an end-to-end model of the matchhighlight passages path; that model drives only highlightDocuments(...), whose provider never returns null. Reading the interface it relies on showed the documented null return and the unguarded strategies. The finder's JUnit (a provider that returns null for every field: positions field, DOCS_AND_FREQS field, offsets control) failed 2 of 3 with -ea and -da. A later minimisation step replaced it with the one-method test shown here.

Prior reports, history and checks

Prior reports

No earlier report found in GitHub issues and PRs (including the JIRA archive), commits, open PRs, the mailing lists, downstream projects or the web (searched 2026-10-26 and again 2026-10-30). The closest items are #12881, which added the null clause, and #15577, which added the empty-list early return.

Searches3 searches

Context, not reports: #12881 (introduced the null clause; no null discussion); #15577 (empty-list early return, no null check); #9820 / LUCENE-8776 (open, offsets going backwards; a comment pastes a patch that calls doc.getValues(field)); #10783 / LUCENE-9744 and #11113 / LUCENE-10075 (other NPEs, in interval and disjunction match iterators); #16 (open 2021 PR, one more getValues loop without a null check); #16847 (open, doc-id conversions only); #11983 (made the strategy constructors public). The JIRA archive was frozen in 2022, before the null clause existed. Limits: keyword search only; private channels are not searchable.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub, mailing lists, JIRA archive, web (search script, three specs)2026-10-3059 network queries per spec: 30 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA archive; plus 21 web searchesno report of this bug; the closest hits are #9820, #12881 and #15577
GitHub (apache/lucene, all of GitHub, Elasticsearch, OpenSearch, Solr)2026-10-3047 manual searches (class and method names, the exception messages, the javadoc phrase, label:module:highlighter since 2023-12); the changed files of all 408 open apache/lucene PRsno report; only #16 and #16847 touch matchhighlight/, neither about null values
The study's search2026-10-26five search-script runs, 45 manual GitHub searches, code, JIRA, list and web searches, the open-PR file scanno report; the same closest items

History

Introduced in 10.0.0 by #12881 (755de5aae33, 2023-12-07), which documented null as an allowed return while the strategies kept their unguarded list use from 9.0.0. Every release from 10.0.0 to 10.6.0 and main are affected; not fixed upstream.

Versions tested7 rows

755de5aae33 (#12881, "Performance improvements to MatchHighlighter and MatchRegionRetriever", Dawid Weiss, merged into main 2023-12-07, not backported to 9.x) rewrote FieldValueProvider and added the null clause. The dereferences date from LUCENE-9463 (150a8dacb59, 2020-08-14, first released in 9.0.0), when the contract had no null clause and the only provider never returned null, so the defect is the contract change. git log -S on the clause and git blame find only 755de5aae33; git tag --contains lists only 10.0.0 to 10.6.0. #15577 (548ae997d12, 2026-01-15, main only) added the empty-list early return, which moved the main NPE from values.size() (:96) to values.isEmpty() (:70). Upstream main 2f424fd428da and branch_11x d0c0decc6606 (2026-10-30) are unchanged.

Versions and branches executed, with results
Version or commitVerdictResult
9.12.0 outside the contractthe minimal test does not compile (API from #12881); a 9.x-API variant throws the same NPEs, but null was not allowed there
10.0.0 first releaseNPE at OffsetsFromPositions.java:96 (-ea, -da); DOCS_AND_FREQS + null at OffsetsFromTokens.java:74, empty-list control passes
10.1.0 to 10.5.2 affectedthe finder's JUnit fails on all 11 releases
10.6.0 (4ae946e5a9b9) affectedNPE at OffsetsFromPositions.java:96 (-ea, -da); DOCS_AND_FREQS + null at OffsetsFromTokens.java:74
main 645d4fe14a20 affectedNPE at OffsetsFromPositions.java:70, gradle and standalone, assertions on and off
upstream main 2f424fd428da, branch_11x d0c0decc6606 source readnull clause, List.of() mapping and unguarded dereferences unchanged (2026-10-30)
main 645d4fe14a20 + null-as-empty fix fixthe minimal test passes; the whole highlighter suite: 35 classes, 723 tests, 0 failures

Independent checks

The finder, independent verifiers, an adjudication, a credibility audit and a tie-break reproduced it on main and releases. A reading-only cross-model review agreed on the root cause; its refute-lens review said not real only because it could not run code. This doc's runs reproduced it and validated the fix through gradle.

Checks and verdicts324 words

Every executed check reproduced the same NPE; every empty-list, built-in-path and offsets-field term-query control passed on main. The fact check added one case: on a field indexed with offsets, an Intervals.extend query with null throws through the OffsetsFromMatchIterator fallback.

Each independent check and its verdict
CheckVerdictWhat it found
finder (code reading, then a JUnit) candidateFails 2 of 3 on main (-ea, -da), 10.0.0 and 10.5.2; 3 of 3 pass with the fix; 48 matchhighlight tests pass with the fix.
Independent reproducer realOwn 7-test JUnit outside the package with a Map-backed provider, each test a real value, an empty list, then null: every strategy fails at the null step after the real-value and empty-list steps pass (6 of 7); 7 of 7 with the fix.
Cross-model review (OpenAI GPT-5.6, reading only) intended lens: real, medium; refute lens: not real (procedural)Neither lens could run code. The refute lens agrees on the root cause and that the inputs are in contract; its one substantive argument is answered in the devil's advocate above.
Intended-behaviour devil's advocate real, impact lowFive not-a-bug arguments, none holds for the "does not exist" case; its unstored-field JUnit fails 2 of 5 on main with the controls passing.
adjudication confirmed, highRe-ran the independent reproduction (-ea, -da: 6 of 7 fail) and the intended-behaviour check (-ea: 2 of 5 fail).
credibility audit keepThe intended-behaviour check re-run on main with -ea and -da: the same NPEs, controls pass.
current-main check and tie-break (2026-10-30) realMinimal test, the independent reproduction, the intended-behaviour check and new null / empty / built-in controls on 645d4fe14a20, 10.5.2 and 10.6.0; the fix shadow passes.
this doc's runs (2026-10-30) reproduced; fix validatedThe minimal test fails on main 645d4fe14a20 (gradle and standalone, -ea and -da) and on 10.6.0; with the fix it passes, and the whole highlighter suite runs clean (35 classes, 723 tests, 0 failures).

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,394 words

Finding record

LFV-606, snapshot of 2026-11-06

Registry title
MatchRegionRetriever.highlightDocument throws NullPointerException when a custom FieldValueProvider returns null for a matched field, which FieldValueProvider#getValues documents as allowed ('null if the field is not loaded or does not exist'), instead of returning no hit regions
Area
highlighter / matchhighlight: MatchRegionRetriever low-level API (highlightDocument with a custom FieldValueProvider) and the offset strategies OffsetsFromPositions, OffsetsFromTokens, OffsetsFromValues and the OffsetsFromMatchIterator fallback
Priority
Low, rank 658: MatchRegionRetriever throws an NPE when a hand-written FieldValueProvider returns null as its javadoc allows, a loud crash reachable only through the expert highlightDocument path with a custom provider.
Impact class
crash
Impact note
Hits only applications that call the public low-level MatchRegionRetriever.highlightDocument(...) with their own FieldValueProvider and return null, as its javadoc allows, for a highlighted field that the query matched and whose offsets must be recomputed from the field's text (a positions-only field through the default OffsetsFromPositions, a DOCS or DOCS_AND_FREQS field through the default OffsetsFromTokens, the OffsetsFromMatchIterator fallback, or OffsetsFromValues): that call throws an unchecked NullPointerException instead of returning no hit regions, deterministically, with default codec and settings, with -ea and -da, and with no scale needed; there are no wrong results, no corruption and no hang, a field indexed with offsets is affected only when a match carries no offsets (for example Intervals.extend), and MatchHighlighter and highlightDocuments(...) are not affected because the built-in provider returns an empty list; workaround: return an empty list instead of null.
Summary
You call MatchRegionRetriever.highlightDocument with your own FieldValueProvider, for example one backed by a Map built from the document's stored fields, and for a field that is indexed but not stored it returns null, exactly as FieldValueProvider#getValues documents ('null if the field is not loaded or does not exist'). You expect no hit regions for that field, which is what the same provider gives when it returns an empty list and what the in-tree highlightDocuments path gives on the same index. Instead the default offset strategy uses the null list and the call throws NullPointerException (OffsetsFromPositions.java:70 for a positions-only field, OffsetsFromTokens.java:74 for a DOCS or DOCS_AND_FREQS field; the opt-in OffsetsFromValues fails at OffsetsFromValues.java:51).
Introduced
755de5aae331301acf4c1acd3af6d7329998ee48 (#12881 'Performance improvements to MatchHighlighter and MatchRegionRetriever', author dweiss, merged into main only and never backported to 9.x). It rewrote FieldValueProvider (List<String>, extends Iterable<String>) and added the javadoc '@return ... or {@code null} if the field is not loaded or does not exist for the field'; in the same commit the only in-tree provider (StoredFieldsVisitor.getValues) maps a missing field to List.of(), and none of the strategies got a null check [read: git log -S on the javadoc text and git blame find only this commit; no review comment on #12881 discusses null]. The unguarded dereferences themselves date from LUCENE-9463 (150a8dacb59, lucene-solr#1750, 2020-08-14, first released in 9.0.0), when the 9.x interface had no null clause and its in-tree provider never returned null, so the defect is the contract change, not a code change at the crash sites (2023-12-07 (#12881 merged into main))
First affected version
10.0.0 [exec: the finder's JUnit fails 2 of 3 with -ea and with -da]. Not affected: the 9.x line, whose FieldValueProvider contract had no null clause (a 9.x-API variant of the scenario throws the same NullPointerException on 9.0.0, 9.9.0 and 9.12.3 [exec], but there null was outside the contract); 8.x and older have no matchhighlight package
Affected versions
every release from 10.0.0 through 10.6.0 and main a9779ef3c193 and 645d4fe14a20, never fixed. [exec] the finder's JUnit fails 2 of 3 on 10.0.0 (-ea and -da), 10.1.0, 10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1 (-ea), 10.5.2 (-ea and -da) and main (-ea and -da); the independent reproduction's JUnit fails on main (-ea and -da), 10.5.2 (-ea and -da) and 10.0.0 (-ea); the intended-behaviour check's JUnit fails on main (-ea and -da) and 10.5.2; the minimal test fails on the 10.6.0 release jars (4ae946e5a9b9) and on main 645d4fe14a20, -ea and -da (2026-10-30). [read] branch_10_6 (2763db11b0a) and the unreleased branch_10x (1309534296e), both 2026-10-24, have the same null clause and the same unguarded dereferences (the two branch heads were read, not executed). Caveat: on 10.x releases a positions-only field also throws for an EMPTY list (RuntimeException 'One of the offsets missing for position range'), which main removed in #15577 (548ae997d12, in no release), so on releases the null-specific failure is the DOCS / DOCS_AND_FREQS one (OffsetsFromTokens) and OffsetsFromValues. Releases were run on JDK 25 with SHA-1-verified Maven Central jars. No open upstream PR changes the handling of getValues (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 adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (2026-10-26, read-only). The follow-up's consolidated the prior-report record had not been written when this row was made (2026-10-26), so this verdict rests on the follow-up's raw search records: five scripted prior-report searches (the finder's: 38 queries, 902 distinct hits, 0 high; follow-up runs A, A-web, B and C: 51 to 54 queries each over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, 85 to 1,622 distinct hits, the only high-overlap hit being #9820), 45 manual GitHub searches (32 over apache/lucene issues, PRs and commits, 13 over all of GitHub and the Solr, Elasticsearch and OpenSearch repositories, which have no hits for MatchRegionRetriever or matchhighlight), 7 code searches, 20 JIRA queries, 5 web searches (the Stack Overflow API was throttled), the changed files of all 407 open apache/lucene PRs, and the fetched texts of #9820, #12881 (with review comments), #15577 (with review comments), #16, #16847, #10783, #11113, #13103, #16820, lucene-solr#1721, #1750 and #1820 and three list threads. Nothing reports the null-return NullPointerException. Closest items, not reports: #9820 / LUCENE-8776 (open; its comments carry a patch with a custom OffsetsFromPositions that calls doc.getValues, about backward offsets), #12881 (introduced the null clause; no null discussion), #15577 (548ae997d12, main only: the empty-values early return, no null check), #16 (open, LUCENE-9634, about extended intervals, 9.x-era code in the same files) and #16847 (open, long doc ids; touches the package only for doc-id conversions); no open PR changes the handling of getValues
Lucene commit tested
main 645d4fe14a20 (11.0.0-SNAPSHOT), 2026-10-30: the minimal test through gradle in a scratch worktree with assertions on and off, stock and with the recommended fix, plus the whole lucene/highlighter test suite with the fix and without it; the standalone runner with -ea and -da; the 10.6.0 release jar (-da). Earlier runs in this hunt: 10.0.0 and 10.6.0 (minimal test, -ea and -da), 10.1.0 to 10.5.2 (the finder's JUnit), 9.12.0 (a 9.x-API variant), and main a9779ef3c193, where lucene/highlighter is identical. Upstream main 2f424fd428da and branch_11x d0c0decc6606 were read on 2026-10-30. Only Linux aarch64 and OpenJDK 25.0.3 were run.

Lucene and upstream sources

Finding record
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/MatchRegionRetriever.java:99-104 (at a9779ef3c193) (FieldValueProvider#getValues '@return Return a list of values for the provided field name or {@code null} if the field is not loaded or does not exist for the field'), :390-392 (highlightDocument 'Low-level method ... can be used with custom document FieldValueProvider'), :434 (offsetStrategy.get(matchesIterator, doc)), :535-538 (StoredFieldsVisitor.getValues returns List.of() for a missing field)
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromPositions.java:59 (at a9779ef3c193) (convertPositionsToOffsets(positionRanges, doc.getValues(field))), :69-72 ('If values are not stored, there is nothing to highlight...' values.isEmpty() -> List.of())
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromTokens.java:54 (at a9779ef3c193) (values = doc.getValues(field)), :74 (values.size())
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromValues.java:47 (at a9779ef3c193) (values = doc.getValues(field)), :51 (for-each over values)
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromMatchIterator.java:64-66 (at a9779ef3c193) (fallback convertPositionsToOffsets(positionRanges, doc.getValues(field)))
The finding in 30 seconds
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/MatchRegionRetriever.java:93-105, :390-437, :488-505, :535-538, :372
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromPositions.java:59, :69-72
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromTokens.java:54, :74
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromValues.java:47, :51
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromMatchIterator.java:52-57, :64-66
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/ExtendedIntervalsSource.java:89-101 (startOffset/endOffset return -1); lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/TestMatchRegionRetriever.java:393-415 (testDegenerateIntervalsWithOffsets)
  • grep -rn 'FieldValueProvider\|highlightDocument(' lucene/highlighter/src/test (no hits)
Who is affected
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/MatchRegionRetriever.java:372, :390-400, :410-437, :488-505, :535-538; OffsetsFromMatchIterator.java:52-66
Why it looks unintended
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/MatchRegionRetriever.java:93-105, :118-128, :153-163, :390-400
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromPositions.java:69-72
  • grep -rn 'experimental\|lucene.internal' lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight (no hits)
Suggested fix
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromPositions.java:59-72; OffsetsFromTokens.java:54; OffsetsFromValues.java:47
Diagrams
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/MatchRegionRetriever.java:100-104, :434, :535-538; OffsetsFromPositions.java:70

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

Repro files

About the diagrams

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.