A search asks each segment's query weight for a scorer supplier; null means "skip this segment". The completion query's weight never says null: it hands out a supplier, and only that supplier's bulk scorer is null. SuggestIndexSearcher#suggest checks the bulk scorer; the generic IndexSearcher does not.
IndexSearcher#searchvisits each segment and asks the query'sWeightfor aScorerSupplier. Anullsupplier means nothing can match there, and the segment is skipped. The rule:Weight#scorerSupplier"must return null if the scorer is null".- From a non-null supplier it takes a
BulkScorerand callsscore()on it, which feeds the matches to the collector. It does not expect that bulk scorer to benull. - The document suggester (
SuggestField,CompletionQuery) is usually run withSuggestIndexSearcher#suggest, which has its own segment loop. ACompletionQuerycan also go through the genericIndexSearcher#searchwithTopSuggestDocsCollectors; without a query timeout that worked in 9.x.
_0 gives 3 suggestions; for segment _1, which has no suggest field, the weight still hands out a supplier whose bulk scorer is null, and IndexSearcher calls score() on it. Select the still to open it at full size. Generated with AI, checked against the code.- Index 3 suggestions (
apple0-apple2, weights 1-3) withSuggestFieldand commit, then 1 document without the suggest field and commit: 2 segments, defaultIndexWriterConfig. - Run a
PrefixCompletionQuerythat matches all three throughIndexSearcher#search(query, manager)withTopSuggestDocsCollectors. You expect whatSuggestIndexSearcher#suggestreturns on the same reader:apple23.0,apple12.0,apple01.0. 9.12.3 returns exactly those. - Instead:
NullPointerException ... because "scorer" is null. For segment 2 the query's weight still hands out a scorer supplier; only that supplier's bulk scorer isnull.IndexSearcherchecks the supplier, not the bulk scorer, and callsscore()onnull. - One segment is enough if the query's filter returns
Bits.MatchNoBitsfor it:suggest()returns 0 hits,IndexSearcher#searchthrows. With a query timeout set, the NPE comes fromTimeLimitingBulkScorer's constructor.suggest()itself is never affected: it skips anullbulk scorer.
Applications on 10.0.0-10.6.0 or main that run a CompletionQuery through IndexSearcher#search (or SuggestIndexSearcher's inherited search) with TopSuggestDocsCollectors. Once any segment lacks the suggest field, every such search fails. Default settings. Users of suggest() are not affected, and this bug never returns wrong results.
Mechanism, terms and numbers643 words
Weight#scorerSupplier "must return null if the scorer is null" (Weight.java:142 on main 645d4fe14a20), and IndexSearcher#searchLeaf relies on it: it skips a segment only when the supplier is null and otherwise calls score() on the supplier's bulk scorer without a null check (IndexSearcher.java:842-852). CompletionWeight#scorerSupplier returns a supplier for every segment (CompletionWeight.java:93-137); its bulkScorer() returns null when the segment has no terms for the suggest field (:108), has no FST for it (:114), or the query's BitsProducer filter returns Bits.MatchNoBits (:125); the first and third were executed, the no-FST case is by code reading only. So a CompletionQuery run through IndexSearcher#search(Query, CollectorManager) with TopSuggestDocsCollectors, or through the search method SuggestIndexSearcher inherits, throws NullPointerException on such a segment; with a query timeout, TimeLimitingBulkScorer's requireNonNull throws it first (TimeLimitingBulkScorer.java:64-66). SuggestIndexSearcher#suggest, the documented entry point, goes through the Weight#bulkScorer helper and skips the null (SuggestIndexSearcher.java:69-71), so it is not affected. It is a regression in 10.0.0 from #13408 (ddf538d43e9), which moved this weight's three return null lines from the overridden Weight#bulkScorer, which 9.x IndexSearcher null-checked, into ScorerSupplier#bulkScorer: the same call (no query timeout) returns exactly suggest()'s suggestions on 8.11.4 to 9.12.3 and throws on every executed 10.x release, 10.6.0 (branch_10_6) and main. The search fails loudly; nothing is corrupted, and this bug returns no wrong results. The fix returns null from scorerSupplier in the three cases.
- Weight / ScorerSupplier / BulkScorer: a query's per-search
Weightgives, per segment, aScorerSupplier, which gives theBulkScorerthat feeds matches to a collector.Weight.java:142: "It must return null if the scorer is null."Weight.java:145: "@return a ScorerSupplier providing the scorer, or null if scorer is null".ScorerSupplier.java:39-46documents nonullfrombulkScorer(); its default wrapsget(), which "may not return null" (:29). - Document suggester:
SuggestFieldindexes suggestions into an FST per segment (a dedicated completion postings format); aCompletionQuery(PrefixCompletionQuery,RegexCompletionQuery,FuzzyCompletionQuery,ContextQuery) finds the top suggestions. An optionalBitsProducerfilter restricts the documents per segment.CompletionQuery.java:36recommendsSuggestIndexSearcher#suggestto run them. - TopSuggestDocsCollector: the only collector
CompletionScorer#scoreaccepts (CompletionScorer.java:69-72), so it is what a caller ofIndexSearcher#search(Query, CollectorManager)uses.
IndexSearcher.java:842-843:searchLeafasksweight.scorerSupplier(ctx)and skips the segment only if it isnull.:844-852: otherwise it callsbulkScorer(), wraps it in aTimeLimitingBulkScorerwhen a timeout is set (:846-848) and callsscore()on it (:852). No null check.CompletionWeight.java:93-137:scorerSupplieralways returnsnew ScorerSupplier() {...}(itsgetandcostthrowUnsupportedOperationException). The supplier'sbulkScorer()returnsnullat:108(no terms for the field),:114(no FST, "a segment can have a null suggester") and:125(the filter returnedBits.MatchNoBits).- So on such a segment
searchLeafcallsscore()onnull:NullPointerException ... because "scorer" is nullatIndexSearcher.java:852. With a timeout,TimeLimitingBulkScorer's constructor (Objects.requireNonNull,:64-66) throws first. SuggestIndexSearcher.java:64-86:suggestloops over the segments itself, calls the finalWeight#bulkScorerhelper (Weight.java:170-179, which returnsnullfor a null supplier and otherwisescorerSupplier.bulkScorer()), and skips anullresult (:71). That is whysuggest()works.
Index: segment _0 with SuggestField("sug", "apple" + i, i + 1) for i = 0..2, segment _1 with one document without the field. suggest(q, 5, false) returns 3: doc 2 apple2 3.0, doc 1 apple1 2.0, doc 0 apple0 1.0. On main, weight.scorerSupplier(leaf) is non-null on both leaves, and supplier.bulkScorer() is a CompletionScorer on leaf 0 and null on leaf 1; IndexSearcher#search(q, manager(5)) throws the NPE at IndexSearcher.java:852. On 9.12.3 the same IndexSearcher#search returns the same 3 suggestions. A one-segment index with a MatchNoBits filter: suggest() returns 0, IndexSearcher#search throws.
Exposure and likelihood270 words
- Who is exposed. Code that runs a
CompletionQuerythroughIndexSearcher#search(Query, CollectorManager), for example to send every query type through one search entry point, or through thesearchmethodSuggestIndexSearcherinherits fromIndexSearcher.SuggestIndexSearcher#suggestusers are not affected. Elasticsearch and OpenSearch run completion queries through their own per-segment loop with a null check, assuggest()does (read 2026-10-28), so they are not affected; Solr has noCompletionQueryuser. - Default configuration. Default codec (plus the completion postings format that
SuggestFieldneeds), defaultIndexWriterConfig, a plainIndexSearcher. - Deterministic once on that path. Every such search fails once it reaches a segment without a document that has the suggest field (an ordinary index state when only some documents carry suggestions; how common it is in practice was not measured) or a segment for which the query's filter returns
Bits.MatchNoBits(possible on a one-segment index; Lucene's ownTestSuggestField#testSuggestOnAllFilteredDocumentsuses such a filter withsuggest()). The third trigger, a segment with the field but no FST, is by code reading only. - Upgrade trap. The same code runs without an exception on 9.x (and, without a query timeout, returns exactly
suggest()'s suggestions) and fails after upgrading to 10.x. - Effect. A
NullPointerExceptionfrom insideIndexSearcherfails that search call. This bug returns no wrong or partial results, corrupts nothing, and nothing persists. (On the same path a query timeout can also make the search repeat suggestions: that is the separate LFV-969.) - How likely: plausible but uncommon. No downstream user of this path was found (code search found only copies of
SuggestIndexSearcher), and no upstream test runs aCompletionQuerythroughIndexSearcher#search.
The test shows that IndexSearcher#search of a PrefixCompletionQuery (with a TopSuggestDocsCollector manager) over two segments, the second without the suggest field, crashes because CompletionWeight returns a non-null ScorerSupplier whose bulkScorer() is null.
/*
* 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.suggest.document;
import java.io.IOException;
import java.util.Collection;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.NoMergePolicy;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.CollectorManager;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
public class TestCompletionQuerySegmentWithoutSuggestField extends LuceneTestCase {
public void testSearchSkipsSegmentWithoutSuggestField() throws IOException {
Analyzer analyzer = new StandardAnalyzer();
IndexWriterConfig iwc =
new IndexWriterConfig(analyzer)
.setCodec(TestUtil.alwaysPostingsFormat(new Completion104PostingsFormat()))
.setMergePolicy(NoMergePolicy.INSTANCE);
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
Document doc = new Document();
doc.add(new SuggestField("suggest", "apple", 1));
w.addDocument(doc);
w.commit();
w.addDocument(new Document());
try (DirectoryReader reader = DirectoryReader.open(w)) {
CollectorManager<TopSuggestDocsCollector, TopSuggestDocs> manager =
new CollectorManager<>() {
@Override
public TopSuggestDocsCollector newCollector() {
return new TopSuggestDocsCollector(1, false);
}
@Override
public TopSuggestDocs reduce(Collection<TopSuggestDocsCollector> collectors)
throws IOException {
return collectors.iterator().next().get();
}
};
CompletionQuery query = new PrefixCompletionQuery(analyzer, new Term("suggest", "app"));
TopSuggestDocs hits;
try {
hits = new IndexSearcher(reader).search(query, manager);
} catch (NullPointerException e) {
throw new AssertionError(
"expected the suggestion 'apple' (segment 2 has no suggest field), got " + e, e);
}
assertEquals(1, hits.scoreLookupDocs().length);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-967/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/suggest/build/libs/lucene-suggest-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed 5EED0967
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=5EED0967 \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.search.suggest.document.TestCompletionQuerySegmentWithoutSuggestField
- Expected vs actual
- Expected: the one suggestion 'apple' (as SuggestIndexSearcher#suggest and 9.12.3 return; Weight#scorerSupplier must return null when there is no scorer); actual: NullPointerException at IndexSearcher.searchLeaf (-ea and -da).
More files, environment, full output68 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed 5EED0967: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected the suggestion 'apple' (segment 2 has no suggest field), got java.lang.NullPointerException: Cannot invoke "org.apache.lucene.search.BulkScorer.score(org.apache.lucene.search.LeafCollector, org.apache.lucene.util.Bits, int, int)" because "scorer" is null
org.apache.lucene.search.suggest.document.TestCompletionQuerySegmentWithoutSuggestField.testSearchSkipsSegmentWithoutSuggestField(TestCompletionQuerySegmentWithoutSuggestField.java:70)
Caused by: java.lang.NullPointerException: Cannot invoke "org.apache.lucene.search.BulkScorer.score(org.apache.lucene.search.LeafCollector, org.apache.lucene.util.Bits, int, int)" because "scorer" is null
org.apache.lucene.search.IndexSearcher.searchLeaf(IndexSearcher.java:852)
org.apache.lucene.search.IndexSearcher.search(IndexSearcher.java:814)
org.apache.lucene.search.IndexSearcher.lambda$search$0(IndexSearcher.java:784)
java.base/java.util.concurrent.FutureTask.run(FutureTask.java:328)
org.apache.lucene.search.TaskExecutor$Task.run(TaskExecutor.java:171)
[...]Weight#scorerSupplier"must return null if the scorer is null" (Weight.java:142);ScorerSupplier#bulkScorerdocuments nonull.- A 10.0 regression: 9.12.3 null-checked
Weight#bulkScorerand returned the 3 suggestions; #13408 moved the check to the supplier and this weight'sreturn nulllines into it. - Nothing forbids the call: the javadoc recommends
suggest(), andSuggestIndexSearcher's own inheritedsearchcrashes too.
The full argument381 words
- Contract.
Weight.java:137-150: "It must return null if the scorer is null. ... @return a ScorerSupplier providing the scorer, or null if scorer is null".ScorerSupplier.java:39-46:bulkScorer()documents nonull; its default isnew DefaultBulkScorer(get(Long.MAX_VALUE)), andget"may not return null" (:29). TheWeight#bulkScorerhelper's own javadoc shows the signal:if (scorerSupplier == null) { // No docs match return null; }(Weight.java:152-179). The rule is written for the supplier: no sentence says that a non-null supplier'sbulkScorer()is non-null. That follows from the default (which wrapsget(), andget()may not return null), and it is whatIndexSearcher#searchLeafandTimeLimitingBulkScorer'srequireNonNullrely on. The repro's fourth test checks this reading (a non-null supplier whosebulkScorer()is null), not a nullget():CompletionWeight'sget()throws. - The rule was stated when the supplier was added. In the #13319 discussion (2024), iamsanjay wrote "scorerSupplier has to be null if scorer is null", and jpountz, reviewing: "Let's return a null ScorerSupplier? Returning null from
ScorerSupplier#getis not legal." - History. In 9.12.3,
CompletionWeightoverrodeWeight#bulkScorerand returnednullin the same three cases (9.12.3CompletionWeight.java:65-92), andIndexSearcherchecked it:BulkScorer scorer = weight.bulkScorer(ctx); if (scorer != null) {(9.12.3IndexSearcher.java:763-764). #13408 changedIndexSearcherto check only the supplier, madeWeight#bulkScorera final helper, deleted its sentence that it "can return null if this query matches no documents" (from #11897), added therequireNonNulltoTimeLimitingBulkScorer, and movedCompletionWeight's threereturn nulllines unchanged into the newScorerSupplier#bulkScorer. CHANGES (10.0.0,CHANGES.txt:2271-2273): "Weight#bulkScorer() still exists for compatibility, but delegates to ScorerSupplier#bulkScorer()". No CHANGES or MIGRATE entry (MIGRATE.md:1678-1684, the 10.0 Weight contract notes) allows a null bulk scorer; the PR's review comments do not mention null bulk scorers or the suggester. - Misuse is reported explicitly elsewhere in the module.
CompletionScorer.java:69-72throwsIllegalArgumentException("collector is not of type TopSuggestDocsCollector"), so a generic caller with the right collector was anticipated; an NPE from core is not such a signal. - Only direct producer. A scan of main's non-test sources for a literal
return nullin aScorerSupplier#bulkScorer()body (the independent reproduction, repeated by the fact check) finds onlyCompletionWeightandConstantScoreQuery(ConstantScoreQuery.java:173-176), which only forwards a null from its inner supplier.
The devil's-advocate check203 words
The intended-behaviour checker and the reviews by another model family (OpenAI GPT-5.6) built the strongest case: (1) CompletionQuery.java:36 says "Use SuggestIndexSearcher#suggest(CompletionQuery, int, boolean) to execute any query that provides a concrete implementation of this query"; (2) CompletionWeight is deliberately partial (get/cost throw UnsupportedOperationException, explain returns null under a // TODO); (3) the classes are @lucene.experimental; (4) no upstream test runs a CompletionQuery through IndexSearcher#search; (5) SuggestIndexSearcher refuses an executor because the dedup logic is not thread-safe (SuggestIndexSearcher.java:41-42), and LFV-969 shows the generic path repeating hits under a timeout. So a committer could call the path unsupported.
Why it fails: line 36 recommends suggest() but forbids nothing, and IndexSearcher#search accepts any Query; SuggestIndexSearcher is an IndexSearcher, and its inherited search crashes the same way; the path (without a query timeout) worked from 8.11.4 to 9.12.3; the supplier's null rule is an explicit core contract that #13408 itself relied on when it moved IndexSearcher's check to the supplier; and the mismatch exists whatever the caller (the repro's fourth test checks it directly on each segment). The residual point, an unusual path, bears on priority and wording, not on whether it is a bug.
Recommended Return null from CompletionWeight#scorerSupplier for a segment that cannot match (validated)
In CompletionWeight#scorerSupplier, check the three cannot-match cases (no terms for the field, no FST, filter returns MatchNoBits) and return null there, so the supplier's bulkScorer() always builds a CompletionScorer (one file, +28/-24). The new test passes 4 of 4 and the whole suggest module passes, with assertions on and off.
All options and trade-offs4 options · 681 words
One fix was built and validated with gradle; one alternative was run through the standalone runner only; two were not built.
Return null from CompletionWeight#scorerSupplier for a segment that cannot match (validated) (recommended)
Move the three checks from the supplier's bulkScorer() into scorerSupplier() itself and return null there; bulkScorer() then only builds the CompletionScorer. One file, +28/-24. The new top of scorerSupplier:
final LeafReader reader = context.reader();
final Terms terms;
final NRTSuggester suggester;
if ((terms = reader.terms(completionQuery.getField())) == null) {
return null;
}
if (terms instanceof CompletionTerms completionTerms) {
if ((suggester = completionTerms.suggester()) == null) {
// a segment can have a null suggester
// i.e. no FST was built
return null;
}
} else {
throw new IllegalArgumentException(completionQuery.getField() + " is not a SuggestField");
}
BitsProducer filter = completionQuery.getFilter();
final Bits filteredDocs;
if (filter != null) {
filteredDocs = filter.getBits(context);
if (filteredDocs.getClass() == Bits.MatchNoBits.class) {
return null;
}
} else {
filteredDocs = null;
}Trade-offs
- The terms lookup,
suggester()andfilter.getBits(context)now run inscorerSupplier(). By code reading, the two in-tree paths that take a bulk scorer from this supplier (IndexSearcher.java:842-845and theWeight#bulkScorerhelper,Weight.java:170-178, used bySuggestIndexSearcher.java:70) callbulkScorer()right after, so their work per segment, or per intra-segment partition, is the same as before; not measured.Weight.java:137-140allows a supplier to be requested several times per segment; a caller that does so now repeats these checks, a cost that was not reviewed. Weight#scorer(Weight.java:129-135) also callsscorerSupplier(), thenget(). With the fix it returnsnullfor a segment that cannot match instead of throwingUnsupportedOperationExceptionfromget(); on a segment that can match it still throws that exception, now after the checks.- The "is not a SuggestField"
IllegalArgumentExceptionmoves frombulkScorer()toscorerSupplier():IndexSearcher#search,suggest()and theWeight#bulkScorerhelper throw the same exception and message as before, butWeight#scoreron such a field now throws it instead ofUnsupportedOperationException. BothWeight#scorerpoints were checked with an executed probe on stock main and with the fix (not a regression test). ContextQuery's andFuzzyCompletionQuery's weights extendCompletionWeightwithout overridingscorerSupplier, so they get the fix too. A "Bug Fixes" CHANGES line would be usual; none is drafted.
What the checks validated
- Scratch worktree of
645d4fe14a20, gradle: the new test fails 4 of 4 on the stock tree and passes 4 of 4 with the fix, with assertions on and off; the wholelucene/suggestmodule (32 suites, 31 upstream plus the new one; 240 tests, 7 skipped, 0 failed;suggest.document: 62 tests, 58 of them upstream) passes with assertions on and off;:lucene:suggest:check -x test(google-java-format, ecjLint, forbiddenApis) passes. - Standalone runner with the fix shadowed in: 4 of 4 pass (
-ea,-da). - Earlier, by the hunt: the finder's, the independent reproducer's, the refute-lens control and the adjudication's tests pass with the same fix.
- Not run: nightly tests, a benchmark.
Null-check the bulk scorer in IndexSearcher#searchLeaf (core; standalone runner only) (validated by the checks)
BulkScorer scorer = null; if (scorerSupplier != null) { ...; scorer = scorerSupplier.bulkScorer(); } if (scorer != null) { ... } (+4/-1 in IndexSearcher.java).
Trade-offs
The three search tests pass, the contract test still fails. It hides contract violations of every other supplier, leaves the other consumers that use bulkScorer() without a check (LRUQueryCache.java:609, DisjunctionMaxQuery.java:166, ToParentBlockJoinQuery.java:174) exposed if a CompletionQuery is wrapped, and reverses #13408's single null signal. Not recommended.
What the checks validated
Shadowed into the stock main jars: 4 run, 1 failed (only testNoScorerSupplierWhenNothingCanMatch). Not run through gradle.
A no-op BulkScorer for a segment that cannot match.
Trade-offs
Keeps getBits in bulkScorer() and makes the supplier legal, but suggest() would then fetch a leaf collector for segments it now skips. Not built.
A javadoc note on CompletionQuery or CompletionWeight.
Trade-offs
Leaves the contract violation, and the crash in SuggestIndexSearcher's own inherited search. Not built.
Downstream code that sends every query type through one generic IndexSearcher#search call gets an exception on completion requests instead of suggestions; Elasticsearch and OpenSearch run completion queries through their own null-checking loop and are not affected.
Code reading. While building a differential harness for a sibling bug in the same code (repeated suggestions under a timeout), the finder followed a planning probe's printed value, supplier=non-null ... bulkScorer=ok:null, into IndexSearcher, then reduced it to a public-API JUnit and a release matrix.
The method in full127 words
Method: non-formal (the hunt's hunt unit for CompletionWeight/CompletionScorer outside SuggestIndexSearcher#suggest, 2026-10-28, main a9779ef3c193).
- Lead. A planning probe from a cross-model ideas pack printed
leaf 1: supplier=non-null ... bulkScorer=ok:nullwithout following the value further. While building the unit's differential harness for its other candidate (LFV-969), the finder read where thatnullgoes:IndexSearcher#searchLeafchecks only the supplier. - Reduction. A 3-test public-API JUnit (two segments, the second without the suggest field,
suggest()as the oracle): 3 of 3 fail with assertions on and off, 15 of 15 over 5 iterations. A release-portable copy fixed the boundary: 9.12.3 passes, 10.0.0 fails. - Harness context. The unit's model-based harness compared
IndexSearcher#searchwithsuggest()on 1,440 grid points for the sibling bug;suggest()was correct on all of them.
No prior report found (searched 2026-10-28, refreshed 2026-10-29): 207 tool queries over GitHub, commits, downstream repositories, four mailing lists, the JIRA archive and the web, 38 manual GitHub searches and the changed files of all 414 open Lucene PRs. Closest: #13408, which introduced it, and #13319, which stated the rule.
Searches6 searches
Related, not reports of this bug: #13408 (introduced it); #13319 (gave CompletionWeight its always-non-null supplier; its discussion states the null rule); #11897 (documented the old nullable Weight#bulkScorer); elastic/elasticsearch#143972 and #143798 (the same NPE message family in ES|QL, caused by a cached weight that returns a null supplier on a later call, fixed in Elasticsearch; no CompletionWeight). No open PR touches CompletionWeight.java; none of the 16 open PRs that touch IndexSearcher.java, ScorerSupplier.java, Weight.java or suggest/document files adds bulk-scorer null handling. Gaps: keyword search only; GitHub Discussions and chat channels were not searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-28 | four scripted prior-report search specs (symptom, core contract, suggester usage, downstream wording): 64 issue/PR and 12 commit queries; 38 manual searches; 4 commit-message searches | no report; #13408, #13319 and #11897 related |
| Open PRs | 2026-10-28 | the changed files of all 414 open apache/lucene PRs; patches of the 16 that touch the core files or suggest/document grepped | none touches CompletionWeight.java or adds bulk-scorer null handling |
| Downstream (Elasticsearch, OpenSearch, Solr) | 2026-10-28 | 11 tool queries plus manual GitHub searches | only elastic/elasticsearch#143972 / #143798, another cause and fix site |
| Mailing lists | 2026-10-28 | 80 tool queries over dev@, java-user@, issues@ and commits@ | no report |
| JIRA archive and web | 2026-10-28 | 16 JIRA and 24 web queries | no report (the JIRA archive predates the 10.0 regression) |
| GitHub (refresh) | 2026-10-29 | search 'CompletionWeight', 'CompletionQuery NullPointerException', 'scorerSupplier bulkScorer null'; the titles of the 35 apache/lucene items updated since 2026-10-28; commits on CompletionWeight.java | nothing new; last commit on the file b0ebaaed5650 (2025-02-12); upstream main 645d4fe14a20 |
Introduced in 10.0.0 by #13408 (ddf538d43e9, 2024-05-27), which moved this weight's return null lines from the null-checked Weight#bulkScorer into ScorerSupplier#bulkScorer. Executed: 8.11.4 to 9.12.3 throw no exception and, without a query timeout, return exactly suggest()'s suggestions; every executed 10.x release, 10.6.0 (branch_10_6) and main crash.
Versions tested6 rows
Introduced by ddf538d43e9, apache/lucene#13408 "Move bulkScorer() from Weight to ScorerSupplier" (jpountz, opened 2024-05-24, merged 2024-05-27, approved by mikemccand, main only, milestone 10.0.0). Precondition: 838b23ebed1, #13319 "Make Weight#scorerSupplier abstract, Weight#scorer final" (iamsanjay, merged by jpountz on 2024-05-14), which added the "must return null" rule and gave CompletionWeight its always-non-null supplier; that was harmless until #13408, because IndexSearcher still called the overridden, nullable Weight#bulkScorer. No release had the #13319-only state. CompletionQuery exists since 5.3.0 (LUCENE-6459), so this is a regression of an old API.
Not fixed: CompletionWeight.java changed since only by b0ebaaed5650 (#14216, errorprone clean-up, 2025-02-12); upstream main 645d4fe14a20 and branch_10_6 unchanged. The commit boundary rests on reading the parent and child of ddf538d43e9; no commit-level builds were made. Also contain the commit (tags, code unchanged): 10.2.0, 10.2.1, 10.3.0, 10.3.1, 10.5.0, 10.5.1.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the brief's test 4 of 4 fail (gradle and the standalone runner, assertions on and off; 40 of 40 over 10 iterations) |
main a9779ef3c193, branch_10_6 4ae946e5a9b9 | affected | NPE at IndexSearcher.searchLeaf and TimeLimitingBulkScorer.<init>; the finder's and verifiers' tests fail |
| 10.5.2 | affected | the brief's test 4 of 4 (assertions off); the portable test 3 of 3 |
| 10.0.0, 10.1.0, 10.2.2, 10.3.2, 10.4.0 | affected | the portable test 3 of 3 fail (10.0.0 with assertions on and off) |
| 8.11.4, 9.0.0, 9.10.0, 9.11.0, 9.11.1, 9.12.3 | not affected | no exception. Without a query timeout IndexSearcher#search returns exactly what suggest() returns (8.11.4 and 9.0.0 have no timeout leg). With a never-firing timeout 9.12.3 returns the same distinct suggestions, two of them twice ([apple2, apple2, apple1, apple1, apple0]): the separate LFV-969, which is why the timeout legs compare distinct hits only |
| main + fix | not affected | the brief's test 4 of 4; the whole lucene/suggest module, 32 suites and 240 tests, 0 failed (assertions on and off) |
An independent reproducer, an intended-behaviour devil's advocate, an adjudication and a credibility audit re-ran the repros and agree; reviews by another model family (OpenAI GPT-5.6) could only read code and agree on the root cause. The brief re-ran everything on main 645d4fe14a20 and validated the fix with gradle.
Checks and verdicts475 words
The cross-model reviews were reading-only: they could not run Java. The refute-lens review returned a procedural "not real, low" (in its words "not a substantive refutation") while agreeing on the root cause; separate re-runs of the repro failed 3 of 3, and a separate control JUnit failed only on the MatchNoBits leg.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | 3-test JUnit fails 3 of 3 on main (-ea, -da), 15 of 15 over 5 iterations; portable copy fails on 10.0.0, 10.5.2 and branch_10_6, passes on 9.12.3; the fix passes, and the 58 upstream suggest/document tests pass with it. |
| cross-model review, works-as-designed lens | uncertain, low (reading-only) | OpenAI GPT-5.6; root cause agrees, inputs in contract, intended argument moderate. A separate run re-ran the repro: 3 of 3 fail. |
| Independent reproducer | real | Own public-API JUnits fail 4 of 4 on main (-ea, -da; 20 of 20 over 5 iterations), 10.5.2 and 10.0.0, and pass on 9.12.3; executed the MatchNoBits trigger; its own fix passes. |
| Cross-model review, refute lens | not real, low (procedural) | OpenAI GPT-5.6, reading-only; agrees on the root cause. Separate re-runs fail 3 of 3 (two seeds, -ea, -da); a separate control JUnit fails only the MatchNoBits leg and passes with the fix. |
| Intended-behaviour devil's advocate | real | Built the strongest intended case and showed why it fails; re-runs fail 3 of 3 (-ea, -da); its reach probe fails exactly where predicted (the filter leg and the inherited search), its control passes. |
| adjudication | confirmed, high | Own copy with a fixed codec fails 3 of 3 (-ea, -da), 15 of 15 over 5 iterations, passes with the fix; novelty new. A first re-run of the independent reproduction's test failed for a harness reason (randomized codec) and was not counted. |
| credibility audit | keep | Re-run with -da: 3 of 3 fail as expected; score and security reviewed. |
| brief | reproduced, fix validated | New 4-test JUnit on 645d4fe14a20 and 10.5.2 through gradle and the standalone runner; the fix with the whole suggest module and check -x test. |
| fact checks of this doc | reproduced; corrections applied | An independent fact check re-ran the doc's gradle command on main (4 of 4 fail, assertions on and off; 4 of 4 pass with the fix; the suggest module, 240 tests, 0 failed) and the standalone runner on main, 10.5.2 (also with assertions on), 10.0.0 and 9.12.3. A second one, by another model family (OpenAI GPT-5.6, reading only), re-read the sources and logs. Both found the bug as described. Their corrections (the repro's writer settings, the 9.x wording under a timeout, the callers of the supplier and the fix's changed Weight#scorer behaviour, the last checked with an executed probe) are applied; see an unpublished 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.
- PNG 523 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,278 words
- Registry title
- CompletionWeight returns a non-null ScorerSupplier whose bulkScorer() is null, so IndexSearcher#search of a CompletionQuery throws NullPointerException on a segment the query cannot match (regression in 10.0 from #13408)
- Area
- suggest / document (NRT) suggester: CompletionWeight#scorerSupplier vs the Weight/ScorerSupplier contract and IndexSearcher#searchLeaf
- Priority
- Low, rank 368: A loud NullPointerException on an undocumented path (a CompletionQuery through the generic IndexSearcher#search); the documented SuggestIndexSearcher#suggest is unaffected.
- Impact class
- crash
- Impact note
- Applications on 10.0.0-10.6.0 or main that run a CompletionQuery through the generic IndexSearcher#search(Query, CollectorManager<TopSuggestDocsCollector, ...>) instead of the documented SuggestIndexSearcher#suggest get a NullPointerException from IndexSearcher#searchLeaf (or from TimeLimitingBulkScorer's constructor when a query timeout is set) as soon as one segment has no document with the suggest field, has no FST for it, or gets Bits.MatchNoBits from the query's BitsProducer filter (the filter case needs only one segment); the search fails, nothing is corrupted and no wrong results are returned; SuggestIndexSearcher#suggest is not affected; the same call worked through 9.12.3; ordinary scale, default codec and IndexWriterConfig.
- Summary
- You index suggestions with SuggestField and run a PrefixCompletionQuery through IndexSearcher#search with a CollectorManager of TopSuggestDocsCollector, on an index where one segment has no document with the suggest field (or where the query's filter matches no document of a segment). You expect the same suggestions as SuggestIndexSearcher#suggest (3 in the repro), because Weight#scorerSupplier 'must return null if the scorer is null' and IndexSearcher then skips that segment, as 9.12.3 does. Instead CompletionWeight returns a non-null ScorerSupplier whose bulkScorer() returns null, and IndexSearcher#searchLeaf throws NullPointerException ('Cannot invoke BulkScorer.score(...) because "scorer" is null'; with a timeout set, Objects.requireNonNull in TimeLimitingBulkScorer throws it first).
- Introduced
- ddf538d43e94a814f783fcc40728ee45038a03a1 (apache/lucene#13408 'Move bulkScorer() from Weight to ScorerSupplier', by jpountz, approved by mikemccand; main only, milestone 10.0.0): it made Weight#bulkScorer a final helper, moved CompletionWeight's three 'return null' paths (no terms, no FST, MatchNoBits filter) from its overridden Weight#bulkScorer, which IndexSearcher null-checked, into ScorerSupplier#bulkScorer, and changed IndexSearcher to null-check only the supplier. Precondition: 838b23ebed1a (apache/lucene#13319 'Make Weight#scorerSupplier abstract, Weight#scorer final', by iamsanjay, merged by jpountz, 2024-05-14) added the 'It must return null if the scorer is null' rule and gave CompletionWeight its always-non-null supplier, harmless until #13408. [read: commit diffs in the history clone; exec: release boundary 9.12.3 pass / 10.0.0 fail with -ea and -da; no commit-level builds of the parent and child (disk rule)] (2024-05-27 (author date = merge date of ddf538d43e9); precondition #13319 2024-05-14)
- First affected version
- 10.0.0 [exec] (tagged 2024-10-14); last unaffected release 9.12.3 [exec] (no 9.x release contains #13408). CompletionQuery/CompletionWeight exist since 5.3.0 (LUCENE-6459)
- Affected versions
- every 10.x release from 10.0.0 to 10.6.0 and main. [exec] fail: 10.0.0 (-ea and -da), 10.1.0, 10.2.2, 10.3.2, 10.4.0, 10.5.2 and the branch_10_6 head 4ae946e5a9b9 (-da), main a9779ef3c193 (-ea and -da); the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's repros agree on main, 10.0.0 and 10.5.2. [exec] pass (the same 3 suggestions as SuggestIndexSearcher#suggest): 9.12.3 (-ea and -da), 9.11.1, 9.11.0, 9.10.0, and 9.0.0 and 8.11.4 with a legacy copy that omits the timeout leg (no IndexSearcher#setTimeout there). [read] 10.2.0, 10.2.1, 10.3.0, 10.3.1, 10.5.0 and 10.5.1 by git tag --contains ddf538d43e9 and the unchanged CompletionWeight/IndexSearcher null handling. Triggers executed: a segment without the suggest field and a BitsProducer filter returning MatchNoBits for one segment (also on an index where every segment has the field); the no-FST trigger is by code reading only. Not fixed upstream: CompletionWeight.java last changed by b0ebaaed5650 (#14216, errorprone clean-up, 2025-02-12); upstream main head 645d4fe14a20 (2026-10-28) and branch_10_6 unchanged. All runs on Linux aarch64 with JDK 25.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading
- Upstream and prior reports
- none found (the prior-report record, verdict new, searched 2026-10-28, read-only): four independent scripted prior-report search specs (symptom/stack-trace, core ScorerSupplier/Weight contract, suggester usage and downstream wording; 207 planned queries: 64 GitHub issue/PR, 12 commit, 11 downstream, 80 mailing-list (dev@, java-user@, issues@, commits@), 16 JIRA-archive and 24 web, every source ok, 0 errors), 38 hand-written GitHub searches (apache/lucene, Elasticsearch, OpenSearch, Solr and two GitHub-wide), 4 commit-message searches (one 403 rate-limit hit, waited for the reset and re-ran), the changed-file lists of all 414 open apache/lucene PRs (none touches CompletionWeight.java; none of the 16 that touch IndexSearcher.java, ScorerSupplier.java, Weight.java or suggest/document files adds bulkScorer null handling), per-branch history of CompletionWeight.java and the core files (main, branch_11x, branch_10x, branch_10_6, branch_9x), the 6 upstream main commits after a9779ef3c193, every item created since 2026-10-25, and CHANGES.txt/MIGRATE.md on upstream main; 22 plausible hits read, none the same bug. Related, not reports: #13408 (introduced it), #13319 (gave CompletionWeight its supplier; its discussion states the rule 'scorerSupplier has to be null if scorer is null'), #11897 (documented the old nullable Weight#bulkScorer), elastic/elasticsearch#143972 and #143798 (same NPE message family in ES|QL, caused by a query-cache weight that returns a null supplier on a later call and fixed in Elasticsearch; no CompletionWeight). The finder's own scripted prior-report search (51 queries, 2026-10-28), the independent reproduction's, the intended-behaviour check's and the adjudication's searches agree. Refresh 2026-10-28 (GitHub search and REST API, read-only): 'CompletionWeight' in apache/lucene finds only #13319 and three closed JIRA-era items (#7518, #7573, #7698); 'CompletionQuery NullPointerException', 'bulkScorer null suggest' and 'scorerSupplier bulkScorer null' find nothing; none of the apache/lucene items updated since 2026-10-26 concerns suggest or a null bulk scorer; no commit on lucene/suggest since 2026-10-26; upstream main head 645d4fe14a20. Gaps: keyword search only; GitHub Discussions and chat channels not searched.
- Lucene commit tested
- main
645d4fe14a20(commit of 2026-10-28; tested 2026-10-29) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off and 10 iterations; the 10.5.2 release jars (assertions off). Earlier hunt runs on maina9779ef3c193(every involved file identical), thebranch_10_6head4ae946e5a9b9and the releases 8.11.4, 9.0.0, 9.10.0, 9.11.0, 9.11.1, 9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.2, 10.4.0 and 10.5.2. The fix was validated in the scratch worktree: the new test and the wholelucene/suggestmodule with assertions on and off, and:lucene:suggest:check -x test.
- Finding record
a Lucene main checkout lucene/suggest/src/java/org/apache/lucene/search/suggest/document/CompletionWeight.java:93-137 (scorerSupplier always returns a supplier; bulkScorer() returns null at :108, :114, :125; get() and cost() throw UnsupportedOperationException); lucene/core/src/java/org/apache/lucene/search/Weight.java:139-150 ('It must return null if the scorer is null', '@return a ScorerSupplier providing the scorer, or null if scorer is null'); lucene/core/src/java/org/apache/lucene/search/ScorerSupplier.java:29 and :39-46 (get 'may not return null'; bulkScorer documents no null return, default wraps get()); lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:842-852 (searchLeaf null-checks only the supplier); lucene/core/src/java/org/apache/lucene/search/TimeLimitingBulkScorer.java:64-66 (Objects.requireNonNull(bulkScorer)); lucene/suggest/src/java/org/apache/lucene/search/suggest/document/SuggestIndexSearcher.java:69-71 (suggest uses Weight#bulkScorer and skips null); lucene/suggest/src/java/org/apache/lucene/search/suggest/document/CompletionQuery.java:36 ('Use SuggestIndexSearcher#suggest ... to execute any query') [read by the registration check]
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/search/Weight.java:137-150, (at 645d4fe14a20) :152-179; lucene/core/src/java/org/apache/lucene/search/ScorerSupplier.java:28-29, :39-46lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:842-852; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TimeLimitingBulkScorer.java:64-66lucene/suggest/src/java/org/apache/lucene/search/suggest/document/CompletionWeight.java:93-137; (at 645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/document/SuggestIndexSearcher.java:64-86; lucene/suggest/src/java/org/apache/lucene/search/suggest/document/CompletionQuery.java:36; lucene/suggest/src/java/org/apache/lucene/search/suggest/document/CompletionScorer.java:69-72
- Who is affected
lucene/suggest/src/test/org/apache/lucene/search/suggest/document/TestSuggestField.java:473-506 (at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/Weight.java:137-179; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ScorerSupplier.java:28-46; lucene/suggest/src/java/org/apache/lucene/search/suggest/document/CompletionScorer.java:69-72; lucene/suggest/src/java/org/apache/lucene/search/suggest/document/CompletionQuery.java:36, :48; lucene/suggest/src/java/org/apache/lucene/search/suggest/document/SuggestIndexSearcher.java:37, :41-42a Lucene clone releases/lucene/9.12.3 lucene/suggest/src/java/org/apache/lucene/search/suggest/document/CompletionWeight.java:65-92; lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:763-764lucene/CHANGES.txt:2271-2273; (at 645d4fe14a20) lucene/MIGRATE.md:1678-1684
- Suggested fix
lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:842-852; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/Weight.java:129-135, :170-179; lucene/suggest/src/java/org/apache/lucene/search/suggest/document/SuggestIndexSearcher.java:64-86; lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:609; lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:166; lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinQuery.java:174; lucene/suggest/src/java/org/apache/lucene/search/suggest/document/ContextQuery.java:261; FuzzyCompletionQuery.java:256
- History
a Lucene clone (git tag --contains ddf538d43e94: releases/lucene/10.0.0 to 10.6.0)
- TestCompletionQuerySegmentWithoutSuggestField.java (JUnit test):
repro/LFV-967/src/org/apache/lucene/search/suggest/document/TestCompletionQuerySegmentWithoutSuggestField.java(77 lines)
It is made in parallel with this doc; every on-screen value comes from the code or the diagram's executed probe.
assets/LFV-967/bug.png(523 KB)
