All findings · Lucene finding · LFV-967 · completionweight-scorersupplier-null-bulkscorer-npe

CompletionWeight returns a scorer supplier with a null bulk scorer, so IndexSearcher#search of a CompletionQuery throws NullPointerException on a segment it cannot match

Since 10.0, a CompletionQuery run through the generic IndexSearcher#search throws NullPointerException as soon as one segment has no suggest field or the query's filter excludes a whole segment. In the repro SuggestIndexSearcher#suggest returns 3 suggestions and IndexSearcher#search crashes; 9.12.3 returned the same 3.

Summary

Priority
Low, rank 368A loud NullPointerException on an undocumented path (a CompletionQuery through the generic IndexSearcher#search); the documented SuggestIndexSearcher#suggest is unaffected.
Impact
crash
Since
10.0.0 (#13408, 2024)introduced 2024-05-27 · about 2 years
Affected
10.0.0 to 10.6.0 and main
Status
documented
Found by
code reading while building a differential harness, then a 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

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.

Background: how a search visits segments

  1. IndexSearcher#search visits each segment and asks the query's Weight for a ScorerSupplier. A null supplier means nothing can match there, and the segment is skipped. The rule: Weight#scorerSupplier "must return null if the scorer is null".
  2. From a non-null supplier it takes a BulkScorer and calls score() on it, which feeds the matches to the collector. It does not expect that bulk scorer to be null.
  3. The document suggester (SuggestField, CompletionQuery) is usually run with SuggestIndexSearcher#suggest, which has its own segment loop. A CompletionQuery can also go through the generic IndexSearcher#search with TopSuggestDocsCollectors; without a query timeout that worked in 9.x.
Summary card for IndexSearcher#search of a CompletionQuery on two segments: segment _1 has no suggest field, CompletionWeight's scorer supplier is non-null but its bulkScorer() is null, and IndexSearcher calls score() on it and throws NullPointerException; expected the 3 suggestions that suggest() returns, and the fix returns a null supplier for such a segment.
The two-segment example on main: segment _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.

The finding, step by step

  1. Index 3 suggestions (apple0-apple2, weights 1-3) with SuggestField and commit, then 1 document without the suggest field and commit: 2 segments, default IndexWriterConfig.
  2. Run a PrefixCompletionQuery that matches all three through IndexSearcher#search(query, manager) with TopSuggestDocsCollectors. You expect what SuggestIndexSearcher#suggest returns on the same reader: apple2 3.0, apple1 2.0, apple0 1.0. 9.12.3 returns exactly those.
  3. 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 is null. IndexSearcher checks the supplier, not the bulk scorer, and calls score() on null.
  4. One segment is enough if the query's filter returns Bits.MatchNoBits for it: suggest() returns 0 hits, IndexSearcher#search throws. With a query timeout set, the NPE comes from TimeLimitingBulkScorer's constructor. suggest() itself is never affected: it skips a null bulk scorer.

Who is affected

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.

Terms

  • Weight / ScorerSupplier / BulkScorer: a query's per-search Weight gives, per segment, a ScorerSupplier, which gives the BulkScorer that 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-46 documents no null from bulkScorer(); its default wraps get(), which "may not return null" (:29).
  • Document suggester: SuggestField indexes suggestions into an FST per segment (a dedicated completion postings format); a CompletionQuery (PrefixCompletionQuery, RegexCompletionQuery, FuzzyCompletionQuery, ContextQuery) finds the top suggestions. An optional BitsProducer filter restricts the documents per segment. CompletionQuery.java:36 recommends SuggestIndexSearcher#suggest to run them.
  • TopSuggestDocsCollector: the only collector CompletionScorer#score accepts (CompletionScorer.java:69-72), so it is what a caller of IndexSearcher#search(Query, CollectorManager) uses.

The bug

  1. IndexSearcher.java:842-843: searchLeaf asks weight.scorerSupplier(ctx) and skips the segment only if it is null.
  2. :844-852: otherwise it calls bulkScorer(), wraps it in a TimeLimitingBulkScorer when a timeout is set (:846-848) and calls score() on it (:852). No null check.
  3. CompletionWeight.java:93-137: scorerSupplier always returns new ScorerSupplier() {...} (its get and cost throw UnsupportedOperationException). The supplier's bulkScorer() returns null at :108 (no terms for the field), :114 (no FST, "a segment can have a null suggester") and :125 (the filter returned Bits.MatchNoBits).
  4. So on such a segment searchLeaf calls score() on null: NullPointerException ... because "scorer" is null at IndexSearcher.java:852. With a timeout, TimeLimitingBulkScorer's constructor (Objects.requireNonNull, :64-66) throws first.
  5. SuggestIndexSearcher.java:64-86: suggest loops over the segments itself, calls the final Weight#bulkScorer helper (Weight.java:170-179, which returns null for a null supplier and otherwise scorerSupplier.bulkScorer()), and skips a null result (:71). That is why suggest() works.

The numbers

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 CompletionQuery through IndexSearcher#search(Query, CollectorManager), for example to send every query type through one search entry point, or through the search method SuggestIndexSearcher inherits from IndexSearcher. SuggestIndexSearcher#suggest users are not affected. Elasticsearch and OpenSearch run completion queries through their own per-segment loop with a null check, as suggest() does (read 2026-10-28), so they are not affected; Solr has no CompletionQuery user.
  • Default configuration. Default codec (plus the completion postings format that SuggestField needs), default IndexWriterConfig, a plain IndexSearcher.
  • 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 own TestSuggestField#testSuggestOnAllFilteredDocuments uses such a filter with suggest()). 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 NullPointerException from inside IndexSearcher fails 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 a CompletionQuery through IndexSearcher#search.

Minimal repro

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.

TestCompletionQuerySegmentWithoutSuggestField.java (JUnit test)77 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.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);
      }
    }
  }
}
Command
# 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).

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

More files, environment, full output68 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# 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)
       [...]

Why it looks unintended

  • Weight#scorerSupplier "must return null if the scorer is null" (Weight.java:142); ScorerSupplier#bulkScorer documents no null.
  • A 10.0 regression: 9.12.3 null-checked Weight#bulkScorer and returned the 3 suggestions; #13408 moved the check to the supplier and this weight's return null lines into it.
  • Nothing forbids the call: the javadoc recommends suggest(), and SuggestIndexSearcher's own inherited search crashes 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 no null; its default is new DefaultBulkScorer(get(Long.MAX_VALUE)), and get "may not return null" (:29). The Weight#bulkScorer helper'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's bulkScorer() is non-null. That follows from the default (which wraps get(), and get() may not return null), and it is what IndexSearcher#searchLeaf and TimeLimitingBulkScorer's requireNonNull rely on. The repro's fourth test checks this reading (a non-null supplier whose bulkScorer() is null), not a null get(): CompletionWeight's get() 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#get is not legal."
  • History. In 9.12.3, CompletionWeight overrode Weight#bulkScorer and returned null in the same three cases (9.12.3 CompletionWeight.java:65-92), and IndexSearcher checked it: BulkScorer scorer = weight.bulkScorer(ctx); if (scorer != null) { (9.12.3 IndexSearcher.java:763-764). #13408 changed IndexSearcher to check only the supplier, made Weight#bulkScorer a final helper, deleted its sentence that it "can return null if this query matches no documents" (from #11897), added the requireNonNull to TimeLimitingBulkScorer, and moved CompletionWeight's three return null lines unchanged into the new ScorerSupplier#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-72 throws IllegalArgumentException("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 null in a ScorerSupplier#bulkScorer() body (the independent reproduction, repeated by the fact check) finds only CompletionWeight and ConstantScoreQuery (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.

Suggested fix

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() and filter.getBits(context) now run in scorerSupplier(). By code reading, the two in-tree paths that take a bulk scorer from this supplier (IndexSearcher.java:842-845 and the Weight#bulkScorer helper, Weight.java:170-178, used by SuggestIndexSearcher.java:70) call bulkScorer() right after, so their work per segment, or per intra-segment partition, is the same as before; not measured. Weight.java:137-140 allows 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 calls scorerSupplier(), then get(). With the fix it returns null for a segment that cannot match instead of throwing UnsupportedOperationException from get(); on a segment that can match it still throws that exception, now after the checks.
  • The "is not a SuggestField" IllegalArgumentException moves from bulkScorer() to scorerSupplier(): IndexSearcher#search, suggest() and the Weight#bulkScorer helper throw the same exception and message as before, but Weight#scorer on such a field now throws it instead of UnsupportedOperationException. Both Weight#scorer points were checked with an executed probe on stock main and with the fix (not a regression test).
  • ContextQuery's and FuzzyCompletionQuery's weights extend CompletionWeight without overriding scorerSupplier, 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 whole lucene/suggest module (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.

Return an empty BulkScorer instead of null in the three cases

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.

Document that only SuggestIndexSearcher#suggest may run a CompletionQuery

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 note

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.

How it was found

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:null without following the value further. While building the unit's differential harness for its other candidate (LFV-969), the finder read where that null goes: IndexSearcher#searchLeaf checks 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#search with suggest() on 1,440 grid points for the sibling bug; suggest() was correct on all of them.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-28four 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 searchesno report; #13408, #13319 and #11897 related
Open PRs2026-10-28the changed files of all 414 open apache/lucene PRs; patches of the 16 that touch the core files or suggest/document greppednone touches CompletionWeight.java or adds bulk-scorer null handling
Downstream (Elasticsearch, OpenSearch, Solr)2026-10-2811 tool queries plus manual GitHub searchesonly elastic/elasticsearch#143972 / #143798, another cause and fix site
Mailing lists2026-10-2880 tool queries over dev@, java-user@, issues@ and commits@no report
JIRA archive and web2026-10-2816 JIRA and 24 web queriesno report (the JIRA archive predates the 10.0 regression)
GitHub (refresh)2026-10-29search 'CompletionWeight', 'CompletionQuery NullPointerException', 'scorerSupplier bulkScorer null'; the titles of the 35 apache/lucene items updated since 2026-10-28; commits on CompletionWeight.javanothing new; last commit on the file b0ebaaed5650 (2025-02-12); upstream main 645d4fe14a20

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe 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 affectedNPE at IndexSearcher.searchLeaf and TimeLimitingBulkScorer.<init>; the finder's and verifiers' tests fail
10.5.2 affectedthe 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 affectedthe 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 affectedno 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 affectedthe brief's test 4 of 4; the whole lucene/suggest module, 32 suites and 240 tests, 0 failed (assertions on and off)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate3-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 realOwn 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 realBuilt 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, highOwn 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 keepRe-run with -da: 3 of 3 fail as expected; score and security reviewed.
brief reproduced, fix validatedNew 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 appliedAn 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.

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

Finding record

LFV-967, snapshot of 2026-11-06

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 main a9779ef3c193 (every involved file identical), the branch_10_6 head 4ae946e5a9b9 and 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 whole lucene/suggest module with assertions on and off, and :lucene:suggest:check -x test.

Lucene and upstream sources

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-46
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:842-852; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TimeLimitingBulkScorer.java:64-66
  • lucene/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-42
  • a 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-764
  • lucene/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)

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

Repro files

About the diagrams

It is made in parallel with this doc; every on-screen value comes from the code or the diagram's executed probe.

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.