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.
MatchRegionRetrievercomputes, for a document the query matched, the character ranges ("hit regions") to highlight in each matched field.- 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. highlightDocuments(...)uses a built-in provider over the stored fields; the low-levelhighlightDocument(...)takes your own provider.
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.- Index one document whose field
body= "foo bar" is indexed with positions but not stored (TextField,Field.Store.NO). The querybody:foomatches it. - Call the low-level
highlightDocument(...)with your ownFieldValueProvider.bodyis not in the stored document, sogetValues("body")returnsnull, as its javadoc allows: "null if the field is not loaded or does not exist". - 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. - Instead it throws
NullPointerException("values" is null) atOffsetsFromPositions.java:70, with assertions on and off: the default offset strategies callvalues.isEmpty()orvalues.size()on it without a null check. - Only callers with their own provider are hit:
MatchHighlighterandhighlightDocuments(...)use a built-in provider that returns an empty list. Workaround: returnList.of().
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 contract.
FieldValueProvideris 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). - 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 callsoffsetStrategy.get(matchesIterator, doc)(:434) and stores non-empty ranges (:435-437). - The default strategy per field (
:488-505): positions without offsets useOffsetsFromPositions(:492-493); positions with offsets useOffsetsFromMatchIteratorwith anOffsetsFromPositionsfallback (:489-490);DOCS,DOCS_AND_FREQSandDOCS_AND_CUSTOM_FREQSuseOffsetsFromTokens(:495-505). - No strategy checks for
null:OffsetsFromPositions.java:59passesdoc.getValues(field)on, and:69-72does
// If values are not stored, there is nothing to highlight...
if (values.isEmpty()) { // :70 NullPointerException when values == null
return List.of();
}OffsetsFromTokens.java:54reads the list and:74callsvalues.size().OffsetsFromValues.java:47reads it and:51loops over it (a public strategy a caller may choose; never a default).OffsetsFromMatchIterator.java:64-66falls back toOffsetsFromPositionswithdoc.getValues(field)as soon as one match reports a negative offset (:52-57). That happens on a field indexed with offsets too: matches ofIntervals.extendalways report offset -1 (ExtendedIntervalsSource.java:89-101inlucene/queries), and Lucene's owntestDegenerateIntervalsWithOffsets(TestMatchRegionRetriever.java:393-415) uses this fallback with the built-in provider.
- Why in-tree callers never see it. The only in-tree provider, the private
StoredFieldsVisitor.getValues(:535-538), returnsvalues == null ? List.of() : values, and the only in-tree call ofhighlightDocument(:372) passes it. No Lucene test implementsFieldValueProvideror callshighlightDocumentdirectly.
same index, query body:foo, unstored positions field body | result |
|---|---|
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.0 | NullPointerException 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, provider | result |
|---|---|
body:foo (term query), null | hit 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, null | NullPointerException 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-writtenFieldValueProviderthat returnsnullfor 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 anIntervals.extendquery, whose matches report offset -1), or the opt-inOffsetsFromValues. 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.
MatchHighlighterandhighlightDocuments(...)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 ofMatchRegionRetrieverin Solr, Elasticsearch or OpenSearch. - Effect. An unchecked
NullPointerExceptionfor 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 ofnull.
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.
/*
* 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);
}
}
}
}
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
highlightDocumentreturns normally and the output map stays empty: no hit regions for a field with no values.- Observed
NullPointerException("values" is null) atOffsetsFromPositions.java:70on main (:96on 10.0.0 to 10.6.0), with assertions on and off.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.0.0 and 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 (the only platform run; pure Java logic) |
# 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)getValuesdocumentsnull"if the field is not loaded or does not exist";highlightDocumentis 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
nulland 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.experimentalor@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 fornull. - History: #12881 (
755de5aae33, 2023-12-07) replaced the 9.x interfaceList<CharSequence> getValues(String field) throws IOException, which had no null clause and whose default provider never returnednull. In the same commit it added the null clause, wrote the new provider to returnList.of()for a missing field, and changed the strategies only fromList<CharSequence>toList<String>. Its body, reviews and comments never discussnull; MIGRATE.md calls it "Minor API changes", and neither it nor CHANGES.txt mentionsnull. - Tests: no upstream test returns
nullfrom 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.
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.
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
OffsetsRetrievalStrategythat callsgetValuesstill has to handlenullitself. - 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
getValuesloop inOffsetsFromMatchIterator; it would need the same check if it is ever merged.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20with the repro test added, seed DEADBEEF: the repro test passes with-Ptests.asserts=trueandfalse. - The whole
:lucene:highlighter:testwith the fix, assertions on: 35 classes, 723 tests, 0 failures. The same suite without the fix: 723 tests, 1 failure, the repro. :lucene:highlighter:checkGoogleJavaFormatpasses with the fix.- Earlier hunt runs: with the fix shadowed, the minimal test (main,
-eaand-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 ona9779ef3c193.
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.
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 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.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub, mailing lists, JIRA archive, web (search script, three specs) | 2026-10-30 | 59 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 searches | no report of this bug; the closest hits are #9820, #12881 and #15577 |
| GitHub (apache/lucene, all of GitHub, Elasticsearch, OpenSearch, Solr) | 2026-10-30 | 47 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 PRs | no report; only #16 and #16847 touch matchhighlight/, neither about null values |
| The study's search | 2026-10-26 | five search-script runs, 45 manual GitHub searches, code, JIRA, list and web searches, the open-PR file scan | no report; the same closest items |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 9.12.0 | outside the contract | the 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 release | NPE 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 | affected | the finder's JUnit fails on all 11 releases |
10.6.0 (4ae946e5a9b9) | affected | NPE at OffsetsFromPositions.java:96 (-ea, -da); DOCS_AND_FREQS + null at OffsetsFromTokens.java:74 |
main 645d4fe14a20 | affected | NPE at OffsetsFromPositions.java:70, gradle and standalone, assertions on and off |
upstream main 2f424fd428da, branch_11x d0c0decc6606 | source read | null clause, List.of() mapping and unguarded dereferences unchanged (2026-10-30) |
main 645d4fe14a20 + null-as-empty fix | fix | the minimal test passes; the whole highlighter suite: 35 classes, 723 tests, 0 failures |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (code reading, then a JUnit) | candidate | Fails 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 | real | Own 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 low | Five 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, high | Re-ran the independent reproduction (-ea, -da: 6 of 7 fail) and the intended-behaviour check (-ea: 2 of 5 fail). |
| credibility audit | keep | The 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) | real | Minimal 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 validated | The 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). |
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 483 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,394 words
- 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 wholelucene/highlightertest suite with the fix and without it; the standalone runner with-eaand-da; the 10.6.0 release jar (-da). Earlier runs in this hunt: 10.0.0 and 10.6.0 (minimal test,-eaand-da), 10.1.0 to 10.5.2 (the finder's JUnit), 9.12.0 (a 9.x-API variant), and maina9779ef3c193, wherelucene/highlighteris identical. Upstream main2f424fd428daandbranch_11xd0c0decc6606were read on 2026-10-30. Only Linux aarch64 and OpenJDK 25.0.3 were run.
- 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, :372lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromPositions.java:59, :69-72lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromTokens.java:54, :74lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromValues.java:47, :51lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromMatchIterator.java:52-57, :64-66lucene/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-400lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromPositions.java:69-72grep -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
- TestFieldValueProviderNullValues.java (lucene/highlighter test, one method):
repro/LFV-606/src/org/apache/lucene/search/matchhighlight/TestFieldValueProviderNullValues.java(79 lines)
assets/LFV-606/bug.png(483 KB)
