All findings · Lucene finding · LFV-969 · completionscorer-ignores-bulk-scoring-window

CompletionScorer ignores the BulkScorer [min, max) window, so a CompletionQuery run through IndexSearcher#search with a TopSuggestDocsCollector and a timeout or intra-segment partitions repeats some suggestions and drops others

A CompletionQuery run through IndexSearcher#search with a TopSuggestDocsCollector and a timeout that never fires returns the top 5 of 500 suggestions as [499, 499, 498, 498, 497] instead of [499, 498, 497, 496, 495], with no error. CompletionScorer reruns its whole-segment lookup for every doc-ID window; suggest() is not affected.

Summary

Priority
Low, rank 369Needs the undocumented generic search path for CompletionQuery plus a timeout or intra-segment partitions.
Impact
silent wrong results
Since
5.3.0 (LUCENE-6459, 2015); silent through IndexSearcher since 9.4.0introduced 2015-05-28 · over 11 years
Affected
9.4.0 to 10.6.0 and main with a timeout; 10.0.0 on with partitions
Status
documented
Found by
a planning probe, then a model-based differential harness and 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

CompletionScorer, the scorer behind every CompletionQuery, is handed a doc-ID window [min, max) but never reads it: each call runs the segment's whole top-N lookup and reports the window as done. One whole-segment call, as suggest() makes, is right; several windows or partitions collect the same suggestions several times.

Background: three pieces

  1. The document suggester (lucene/suggest) answers a CompletionQuery with one walk over a segment's suggestion FST that yields the top-N suggestions in score order. Its documented entry point, SuggestIndexSearcher#suggest, calls the scorer once per segment, for all documents.
  2. A BulkScorer scores one doc-ID window per call: score(collector, acceptDocs, min, max) must collect only the matches in [min, max) and return where the next match is, or NO_MORE_DOCS when there is none.
  3. IndexSearcher uses windows in two cases. With setTimeout it scores each segment in growing windows and checks the timeout between them. With intra-segment concurrency it splits a segment into doc-ID partitions, each with its own collector, and merges the results.
Summary card for CompletionScorer and the BulkScorer window: the top 5 suggestions for apple from 500 documents; suggest() returns docs 499 to 495; with a never-firing timeout the scorer is called once per doc-ID window and each call reruns the whole-segment lookup, giving 499, 499, 498, 498, 497; with 5 partitions 499 five times; the candidate fix runs the lookup only for the window that starts at doc 0.
The diagram follows the repro's 500-suggestion example on main 645d4fe14a20: suggest() with one call per segment, the timeout's doc-ID windows each rerunning the whole lookup, the copies in the top-5 queue, the partition leg, and the candidate fix. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 500 documents in one segment, one suggestion each: apple0 to apple499 with weights 1 to 500. SuggestIndexSearcher#suggest for the prefix apple, top 5, returns docs [499, 498, 497, 496, 495].
  2. Run the same PrefixCompletionQuery through IndexSearcher#search with a TopSuggestDocsCollector(5, false) per slice: the same five. Now call setTimeout(() -> false), a timeout that never fires. You expect the same five again.
  3. Instead you get [499, 499, 498, 498, 497], an exact total of 5, and timedOut() false. With a timeout the searcher scores the segment in doc-ID windows of 100, 150, 225 ... documents. CompletionScorer ignores the window and reruns the full top-5 lookup on every call, so window 2 adds 499 and 498 again until the queue overflows.
  4. With 5 intra-segment partitions of 100 documents (an executor plus slices(leaves, 100, 1, true)), every partition collects the whole segment and the merge keeps the copies: [499, 499, 499, 499, 499]. One document with one suggestion comes back 5 times under the timeout.

Who is affected

Applications that run a CompletionQuery through the generic IndexSearcher#search with a TopSuggestDocsCollector and also set a timeout (silent since 9.4.0) or use intra-segment partitions (since 10.0). Deterministic once there, at any size. Rare: the module documents suggest(), which is correct, and no user of the generic path was found.

Mechanism, terms and numbers960 words

CompletionScorer is the BulkScorer of every CompletionQuery in the document suggester (lucene/suggest). BulkScorer#score(collector, acceptDocs, min, max) must collect only the matches in [min, max) (BulkScorer.java:42-43, :72-73), but CompletionScorer.score never reads min or max: every call runs suggester.lookup over the whole segment, a score-ordered top-N walk of its suggestion FST, and returns max (CompletionScorer.java:68-75 on main 645d4fe14a20). The documented entry point, SuggestIndexSearcher#suggest, makes one whole-range call per segment, so it is correct. Through the generic IndexSearcher#search with a TopSuggestDocsCollector it is correct only without a timeout and without intra-segment partitions. With setTimeout, even a timeout that never fires, TimeLimitingBulkScorer scores each segment in up to 40 growing windows (TimeLimitingBulkScorer.java:70-86), so the same top suggestions are collected again until the collector's queue overflows: 500 suggestions, n = 5 give docs [499, 499, 498, 498, 497] instead of [499, 498, 497, 496, 495], with an EQUAL_TO total and timedOut() false; one document with one suggestion comes back 5 times. With intra-segment partitions every partition collects the whole segment and TopSuggestDocs.merge keeps the copies: [499 x5]. Every hit still passes the query's filter, contexts and live docs. The scorer has ignored the window since it was written (LUCENE-6459, 5.3.0); it turned silent through IndexSearcher in 9.4.0 (#996) and gained the partition leg in 10.0.0 (#13542), whose migration notes say such Weights "need updating". Running the lookup only for the window that starts at doc 0 and returning NO_MORE_DOCS fixes both paths; throwing for any other window, as DrillSidewaysScorer does, is the loud alternative.

Terms

  • CompletionQuery (PrefixCompletionQuery, RegexCompletionQuery, FuzzyCompletionQuery, ContextQuery): the queries of the document (NRT) suggester; CompletionWeight's ScorerSupplier#bulkScorer() returns a CompletionScorer (CompletionWeight.java:102-130). The module says: "Use SuggestIndexSearcher#suggest(CompletionQuery, int, boolean) to execute any query" (CompletionQuery.java:36-37).
  • BulkScorer window: "min is the minimum document to be considered for matching. All documents strictly before this value must be ignored" (BulkScorer.java:42-43); "@param max Score up to, but not including, this doc" (:72-73); the return value is NO_MORE_DOCS "if there are no more matches" (:34-40).
  • Timeout windows: with a QueryTimeout, IndexSearcher#searchLeaf wraps the scorer in TimeLimitingBulkScorer (IndexSearcher.java:846-848), which calls it for [min, min + interval) with an interval of 100 that grows by 50% per call, checking the timeout before each, and continues from the returned value (TimeLimitingBulkScorer.java:70-86). An entire segment is the range [0, NO_MORE_DOCS) (IndexSearcher.java:1096-1100).
  • Partitions: with an executor and slices(leaves, maxDocs, maxSegments, true), a segment is split into doc-ID ranges searched with different collectors (MIGRATE.md:1668-1684).

The bug

  1. CompletionScorer.score(collector, acceptDocs, min, max) checks the collector type, calls suggester.lookup(this, acceptDocs, (TopSuggestDocsCollector) collector) and returns max (CompletionScorer.java:68-75). min and max are never read; the class javadoc still says score "is called for each leaf reader" (:30).
  2. Under a timeout, the returned max sends TimeLimitingBulkScorer on to the next window, so the loop walks the whole int range: up to 40 calls per segment, each running the full lookup into the same collector.
  3. TopSuggestDocsCollector keeps a top-n queue and throws CollectionTerminatedException at the first insert that overflows, on the assumption that every later hit of the segment scores lower (TopSuggestDocsCollector.java:123-135). searchLeaf catches it and moves on (IndexSearcher.java:853-855), so the search succeeds.
  4. With partitions, each partition's call runs the whole lookup into its own collector, and TopSuggestDocs.merge merges them into a top n with no de-duplication and an EQUAL_TO total (TopSuggestDocs.java:115-132).

The numbers

500 documents in one segment, suggestion apple<i> with weight i + 1, PrefixCompletionQuery("apple"), n = 5, skipDuplicates false. suggest(): docs [499, 498, 497, 496, 495]. IndexSearcher#search with a TopSuggestDocsCollector per slice and no timeout: the same. With setTimeout(() -> false): window 1 fills the queue with 499 to 495; window 2 adds 499 and 498 again, which push out 495 and 496; 497 then overflows and the segment ends: [499, 499, 498, 498, 497], total 5 EQUAL_TO, timedOut() false. One document with one suggestion: the same hit 5 times. 5 partitions of 100 documents: [499, 499, 499, 499, 499]. A direct bulkScorer.score(collector, null, 0, 100) collects 400 documents outside [0, 100).

When it happens and when not

  • Needs: a CompletionQuery executed through IndexSearcher#search with a TopSuggestDocsCollector (a hand-written CollectorManager, or the deprecated search(Query, Collector), which SuggestIndexSearcher inherits), plus any QueryTimeout on that searcher or intra-segment partitions.
  • The shortest trigger: suggestSearcher.setTimeout(() -> false) and then suggestSearcher.search(query, new TopSuggestDocsCollector(5, false)); on 20 suggestions it gives [19, 19, 18, 18, 17], while suggest() with the same timeout gives [19, 18, 17, 16, 15].
  • Not affected: SuggestIndexSearcher#suggest (SuggestIndexSearcher.java:69-76: one score(..., 0, NO_MORE_DOCS) call per segment); the generic path without a timeout or partitions. Scope: on 10.x the generic path also throws NullPointerException on a segment with no document with the suggest field, or where the query's filter matches no document (sibling LFV-967), so these statements assume segments where CompletionWeight hands out a CompletionScorer, as in the repro.
  • What stays right: every hit passes the filter and the live docs (NRTSuggester.java:207, CompletionScorer.java:89-92), and contexts are part of the same lookup. The lists are wrong, never foreign.
  • With skipDuplicates=true the timeout leg happens to stay correct (the collector's set of seen surface forms survives across windows); the partition leg does not (the finder's model: 0 and 128 of 240 grid points differ).
  • Other generic calls already fail loudly: search(q, n) throws IllegalArgumentException ("collector is not of type TopSuggestDocsCollector", CompletionScorer.java:71), count(q) throws, explain returns null.
  • Side observation: suggest() itself never applies setTimeout; with an expired timeout it still returns every hit and timedOut() stays false.
Exposure and likelihood170 words
  • Who is hit. Code that executes completion suggestions like any other query: a CollectorManager of TopSuggestDocsCollector, or the inherited search(Query, Collector) on a SuggestIndexSearcher, on a searcher that has a timeout or intra-segment partitions. A developer who wants to bound suggest latency has a reason to try setTimeout, because suggest() ignores it.
  • Default or opt-in. Opt-in: the module documents SuggestIndexSearcher#suggest (CompletionQuery.java:36-37, SuggestField.java:45-46); the generic path, the timeout and the partitions are the application's choices.
  • Effect. Silent wrong suggestion lists: copies of the best suggestions, lower ones missing, an EQUAL_TO total, timedOut() false, no exception. No filtered-out or deleted document comes back.
  • Releases. Silent under a timeout from 9.4.0 (9.3.0 throws CollectionTerminatedException), with partitions from 10.0.0, through 10.6.0 and main.
  • Scale. Ordinary: one document under a timeout, one 500-document segment in 5 partitions.
  • How likely: rare. No upstream test and no downstream user of the generic path was found; on 10.x that path also throws NullPointerException on any segment without the suggest field (LFV-967).

Minimal repro

The test shows that a CompletionQuery run through the IndexSearcher#search(Query, Collector) that SuggestIndexSearcher inherits, with a timeout that never fires, returns the same suggestion several times, because CompletionScorer ignores the BulkScorer [min, max) window and reruns its lookup on every window.

TestCompletionQueryUnderTimeout.java (JUnit test)53 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 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.Term;
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 TestCompletionQueryUnderTimeout extends LuceneTestCase {

  public void testNeverFiringTimeoutReturnsSuggestionOnce() throws Exception {
    try (Analyzer analyzer = new StandardAnalyzer();
        Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig iwc = new IndexWriterConfig(analyzer);
      iwc.setCodec(TestUtil.alwaysPostingsFormat(new Completion104PostingsFormat()));
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        Document doc = new Document();
        doc.add(new SuggestField("sug", "apple", 7));
        w.addDocument(doc);
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        SuggestIndexSearcher searcher = new SuggestIndexSearcher(r);
        searcher.setTimeout(() -> false);
        TopSuggestDocsCollector collector = new TopSuggestDocsCollector(5, false);
        searcher.search(new PrefixCompletionQuery(analyzer, new Term("sug", "app")), collector);
        // one document with one suggestion: expected 1 hit, actual 5 (the same suggestion repeated)
        assertEquals(1, collector.get().scoreLookupDocs().length);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-969/, 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 DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.search.suggest.document.TestCompletionQueryUnderTimeout
Expected vs actual
Expected 1 hit for an index with one suggestion; actual 5 (expected:<1> but was:<5>), with -ea and -da.

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

More files, environment, full output34 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<1> but was:<5>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.search.suggest.document.TestCompletionQueryUnderTimeout.testNeverFiringTimeoutReturnsSuggestionOnce(TestCompletionQueryUnderTimeout.java:49)

Why it looks unintended

  • BulkScorer#score must ignore documents before min and stop before max; nothing exempts this scorer or rejects the call.
  • Lucene 10.0's migration notes: Weights that assume one bulk scorer per segment per search "need updating".
  • A timeout that never fires must not change results; the similar whole-segment-only DrillSidewaysScorer throws instead.
The full argument315 words
  • Contract. BulkScorer.java:42-43: "All documents strictly before this value must be ignored"; :72-73: "max Score up to, but not including, this doc". The direct violation is collecting matches outside the window. max is a legal return value in general (:45), but after a lookup of the whole segment it tells the caller to come back for the next window, which runs the lookup again; NO_MORE_DOCS is the value for "no more matches" (:34-40).
  • Migration note (10.0). "Weight implementations that rely on the assumption that a scorer, bulk scorer or scorer supplier for a given LeafReaderContext is requested once per search need updating" (MIGRATE.md:1678-1684 on main; :851-857 in 10.0.0, added by #13542). CompletionScorer.java:30 ("is called for each leaf reader", 2015) reflects that per-leaf calling model, although it does not say "exactly once".
  • Timeouts. setTimeout applies "for all searches that run through this IndexSearcher" (IndexSearcher.java:612); the test framework's newSearcher sets setTimeout(() -> false) at random on half of its concurrent searchers (LuceneTestCaseParent.java:2689-2691; the common overloads pick a concurrent searcher half the time, :2568, :2580-2583, so about a quarter of all searchers get it), treating "a timeout that never fires changes nothing" as an invariant. The suggest tests build SuggestIndexSearcher directly and never meet it.
  • Precedent. DrillSidewaysScorer rejects windows: "min must be 0, got " + min and "maxDoc must be Integer.MAX_VALUE" (DrillSidewaysScorer.java:90-97); facet tests opt it out of the random timeout because it "requires the entire range of docs to be scored at once" (TestRangeFacetCounts.java:473); #12032 (open) treats that as a limitation to fix.
  • History. The silent path was never designed: it appeared when timeouts (#927, #996) and partitions (#13542) began passing the user's collector through windows; none of those PRs changed CompletionScorer or discussed the suggest module (#13542 only switched SuggestIndexSearcher#suggest to the explicit whole-range score(..., 0, NO_MORE_DOCS) call).
The devil's-advocate check240 words

The intended-behaviour checker and a reviewer from another model family (OpenAI GPT-5.6) built the strongest case: the module documents suggest() as the way to run "any query" (CompletionQuery.java:36-37) and a call for each leaf (CompletionScorer.java:30); the generic machinery is visibly unsupported (search(q, n), count(q) and a BooleanQuery clause throw, explain returns null); a score-ordered FST walk cannot honour doc-ID windows; SuggestIndexSearcher refuses an executor; whole-segment-only scorers are a tolerated limitation; the module is @lucene.experimental; and for partitions the 10.0 notes put per-segment logic on the CollectorManager ("Individual collectors don't need to be adapted", MIGRATE.md:1668-1676), so part of the partition handling falls on the application's hand-written manager.

Why it fails: "use suggest()" is guidance, not a prohibition, and nothing rejects the other path; SuggestIndexSearcher is an IndexSearcher and inherits both search(Query, Collector) and setTimeout, and the generic path is correct without a timeout. Where the module did decide against the generic machinery it fails loudly; here it fails silently, unlike the tolerated precedent DrillSideways. Experimental allows API changes, not silent wrong results. The collector note covers collectors, not scorers: the note right after it (MIGRATE.md:1678-1684) asks scorers to cope with several doc-ID ranges, and in the timeout leg one collector receives the copies within one leaf, out of the manager's reach. The adjudication kept the framing "silently ignores the window instead of rejecting it", a documentation and robustness bug.

Suggested fix

Recommended Run the lookup once, on the window that starts the segment

In CompletionScorer.score, run the lookup only for the window that starts the segment (min == 0) and return NO_MORE_DOCS, so a timed or partitioned search collects each suggestion once. Validated: the bug tests and the control pass, as do the suggest module and check. A whole-segment mitigation: a direct windowed call still ignores the window, by design.

// The lookup is a top-N search in score order, not in doc-ID order, so it cannot be split into
// doc-ID windows: run it once, on the window that starts the leaf, and report that this leaf
// has no more matches, so that callers that score in windows (IndexSearcher#setTimeout,
// intra-segment partitions) neither repeat it nor collect it again.
if (min == 0) {
  suggester.lookup(this, acceptDocs, ((TopSuggestDocsCollector) collector));
}
return DocIdSetIterator.NO_MORE_DOCS;
All options and trade-offs4 options · 760 words

One fix was built and validated; a loud alternative was built and run for comparison; two more options were not built.

Run the lookup once, on the window that starts the segment (validated by the checks) (recommended)

A change in CompletionScorer.score (1 file, +9/-2; the finder's sketch, unchanged by tidy). Under a timeout, TimeLimitingBulkScorer checks the timeout, calls [0, 100) once, gets NO_MORE_DOCS and stops: one lookup per segment, as suggest() makes. With partitions, the one that starts at doc 0 collects the segment's suggestions and the others collect nothing. It is a mitigation for these two callers, not an implementation of the window contract.

// The lookup is a top-N search in score order, not in doc-ID order, so it cannot be split into
// doc-ID windows: run it once, on the window that starts the leaf, and report that this leaf
// has no more matches, so that callers that score in windows (IndexSearcher#setTimeout,
// intra-segment partitions) neither repeat it nor collect it again.
if (min == 0) {
  suggester.lookup(this, acceptDocs, ((TopSuggestDocsCollector) collector));
}
return DocIdSetIterator.NO_MORE_DOCS;

Trade-offs

  • A direct windowed call is still not honoured: score(c, acceptDocs, 0, 100) collects the whole segment and a window that does not start at 0 collects nothing. The repro's direct-window test keeps failing with this fix by design; the PR version of the test leaves it out, and the class javadoc (CompletionScorer.java:30) should say so.
  • It relies on one partition of each segment starting at doc 0, as the standard slices(..., true) creates (IndexSearcher.java:405); a custom slices override whose partitions leave out doc 0 would lose the segment's suggestions.
  • The lookup runs as one block, so the timeout is checked once, before it. The stock loop checks again before window 2 (TimeLimitingBulkScorer.java:80), so a timeout that expires during the first lookup is reported today and no longer with the fix. Executed with a timeout that returns false on its first check and true afterwards: stock [499 ... 495] with timedOut() true, the fix the same hits with timedOut() false. The list is complete either way.
  • With partitions, one partition does all of a segment's suggestion work: no intra-segment speed-up, and no repeated work.
  • NO_MORE_DOCS is a legal return value (BulkScorer.java:38).

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle, OpenJDK 25: the repro test fails 4 of 5 on the stock tree; with the fix 4 of 5 pass and only the direct-window test fails, with assertions on and off; the PR test passes 4 of 4.
  • With the fix and the PR test, the whole lucene/suggest module passes with assertions on and off: 32 suites (31 upstream + the PR test), 240 tests, 7 skipped, 0 failures; the 6 upstream suggest.document suites (58 tests) among them.
  • :lucene:suggest:check -x test (checkGoogleJavaFormat, ecjLint, forbiddenApis) passes after tidy joined one line of the test.
  • Earlier, by the hunt: the finder's model grid drops from 448 to 0 points that differ from suggest() (timeout 0 of 480, partitions 0 of 480).
  • Not run: nightly tests, a benchmark.

Reject any other window loudly, as DrillSidewaysScorer does (built and run)

Throw IllegalArgumentException("CompletionScorer can only score a whole segment at once, got [" + min + ", " + max + ")") unless min == 0 && max == NO_MORE_DOCS, and return NO_MORE_DOCS (1 file, +8/-1). The repro's 4 bug tests then throw ... got [0, 100) instead of returning wrong lists; the control passes; the suggest module's 31 upstream suites (236 tests) pass with assertions on.

Trade-offs

Honest, but every CompletionQuery search through IndexSearcher with any timeout or with partitions then throws, including the inherited search(Query, Collector) on a SuggestIndexSearcher with a timeout: the DrillSideways state that #12032 keeps open as a limitation to fix.

Document that IndexSearcher#search, timeouts and partitions are unsupported (not built)

A javadoc sentence on CompletionQuery, CompletionWeight or SuggestIndexSearcher (and that suggest() ignores setTimeout).

Trade-offs

Cheap, but on its own it leaves the silent copies for anyone who misses the note. Best combined with one of the two code options.

Honour the window by filtering the lookup's hits to [min, max) (not built)

Collect only hits whose doc ID is inside the window.

Trade-offs

Satisfies the contract literally, but every window repeats a full score-ordered walk of the FST (up to 40 per segment under a timeout), and TopSuggestDocsCollector ends the whole segment at the first queue overflow on the assumption of one descending-score pass (TopSuggestDocsCollector.java:123, :128-134), which several windows break. More than a local change.

Downstream note

Elasticsearch and OpenSearch run completion suggestions through their own loop with one whole-segment call per segment, as suggest() does, so they are not affected.

How it was found

A planning probe that compared IndexSearcher#search under a timeout with suggest() showed the copies. The hunt then checked an explicit model of the scorer under timeout windows and partitions against the stock jar on 1,440 grid points (448 differ from suggest(), 0 with the fix) and reduced it to a JUnit (public API, plus a protected slices override for partitions).

The method in full213 words

Method: non-formal first signal, then a model-based differential (the hunt's hunt unit for CompletionWeight and CompletionScorer outside SuggestIndexSearcher, 2026-10-28, main a9779ef3c193).

  • Lead. A planning probe on the stock jars, built from the contract "every BulkScorer collects exactly the matches in [min, max)": under a timeout IndexSearcher returned 499 499 498 498 497 where suggest() returns 499 to 495. Its partition leg had no executor, so no partitions were used.
  • Intent gate. Code, javadoc and history were read first, because the module documents suggest() as the entry point; the bug was filed as a documentation and robustness defect.
  • Model. An explicit model of CompletionScorer under timeout windows and intra-segment partitions, checked against the stock jar on a bounded grid: 6 segment sizes x 4 match counts x 2 key layouts x 5 values of n x skipDuplicates on and off x {suggest(), never-firing timeout, 100-document partitions with an executor} = 1,440 points; 0 model disagreements after one model fix (the real windows run to NO_MORE_DOCS, not to maxDoc). 192 timeout points and 256 partition points differ from suggest(); 0 with the fix.
  • JUnit. Reduced to a JUnit (public API, plus a protected IndexSearcher#slices override for the partition test): 4 of 5 fail with assertions on and off.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-28, refreshed 2026-10-29): 464 query records over GitHub, commits, downstream trackers, the mailing lists, the JIRA archive and the web, 67 manual GitHub searches and the changed files of all 414 open Lucene PRs. Upstream discussions of whole-segment-only scorers name only DrillSideways.

Searches6 searches

Related, not reports of this bug: #13542, which added partitions and the migration note and edited SuggestIndexSearcher but not CompletionScorer (its body names DrillSideways as "the only component that does not support intra-segment concurrency"); #12032 (open), DrillSideways under QueryTimeout, the loud twin; #13753 (open), DrillSideways and intra-segment concurrency; #996, whose test comment says DrillSideways "requires the entire range of docs to be scored at once" and which made the copies silent; #7518 (LUCENE-6459), the introducing issue, with no discussion of windows. No open PR touches CompletionScorer.java (#16847 changes doc IDs from int to long in NRTSuggester and TopSuggestDocs*). Tracking issue #16937 does not list this bug. Gaps: keyword search only (GitHub splits CamelCase names poorly); GitHub Discussions and chat channels were not searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs, review comments, commits and downstream trackers2026-10-28a scripted prior-report search with four specs (window contract, timeout symptom, partitions and merge, generic execution) plus the finder's 51-query run; 67 manual searches over apache/lucene, Elasticsearch, OpenSearch, Solr, Lucene.NET and all of GitHub; about 40 items read with all commentsno report; only the related items below
Open PRs2026-10-28the changed files of all 414 open apache/lucene PRs; everything created or updated since 2026-10-26none touches CompletionScorer.java; #16847 touches NRTSuggester and TopSuggestDocs mechanically
Mailing lists2026-10-28tool queries plus 48 manual list queries over dev@, java-user@, issues@ and commits@no report
JIRA archive2026-10-28tool queries and 11 JQL queries on LUCENE and SOLRno report
Web2026-10-2825 distinct web queriesno report
GitHub (re-check)2026-10-29search repo:apache/lucene CompletionScorer; suggest items updated since 2026-10-28; the file's latest upstream commit; the main headonly #9335, #14271, #7518 and #9334 (unrelated); nothing new; file unchanged since 2cbf261032dc (2021); upstream main 645d4fe14a20

History

Introduced with the class in 5.3.0 (LUCENE-6459, be4d58c7740, 2015); not a regression. It turned silent through IndexSearcher in 9.4.0 (#996 caught the collector's termination exception under a timeout; 9.3.0 throws) and gained the partition leg in 10.0.0 (#13542). Unchanged on main.

Versions tested7 rows

Introduced by be4d58c7740 on trunk, "LUCENE-6459: add common suggest API for document based NRT suggester" (patch by Areek Zillur, committed by Michael McCandless, 2015-05-28; JIRA LUCENE-6459, migrated as #7518, no PR), released through the branch_5x backport 380bb29f395 in 5.3.0. score returned max without reading the window from the start; later commits added acceptDocs (d671dd8d890, LUCENE-6553, which also renamed the filter field and changed accept), moved the license header and reformatted (2cbf261032d, 2021), none of them touching the window handling.

Exposure through IndexSearcher, from PRs that never changed CompletionScorer (#13542 only switched SuggestIndexSearcher#suggest to the explicit whole-range call): #927 (LUCENE-10151, af05550ebfe, 9.3.0) added setTimeout with 100-document windows, and the overflow then escaped as CollectionTerminatedException; #996 (81d4a7a69f1, 9.4.0) caught that exception on the timeout path, so the copies turned silent; #11984 (4eba6a12843, 9.5.0) made the windows grow; #13542 (fafd6af004e, 10.0.0) added intra-segment partitions.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 and a9779ef3c193 affected4 of 5 tests fail (gradle and the standalone runner, assertions on and off)
branch_10_6 4ae946e5a9b9, 10.5.2, 10.0.0 affected4 of 5 fail, the partition leg included
9.12.3, 9.4.2, 9.4.0 affectedsilent copies under a timeout and the direct window call; no partitions API
9.3.0 fails loudlythe timeout tests throw CollectionTerminatedException; the direct window call is wrong
9.2.0, 8.11.4, 5.3.0 direct call onlyno setTimeout; a direct score(c, null, 0, 100) collects 400 of 500 outside [0, 100)
5.2.1 API absentno CompletionScorer
main + fix not affectedthe 3 search-level bug tests and the control pass; the suggest module (32 suites, 240 tests) passes, assertions on and off

Independent checks

An independent reproducer, an intended-behaviour devil's advocate and reading-only reviews by another model family (OpenAI GPT-5.6) agree on the root cause; the adjudication re-ran the independent tests and confirmed it with high confidence, as a documentation and robustness bug. This doc re-ran the repro on 645d4fe14a20 and validated the fix with gradle.

Checks and verdicts301 words

The cross-model reviews were reading-only: they could not run Java. The refute-lens review returned a procedural "not real, low" while agreeing on the root cause; a separate re-run failed 4 of 5. An executing cross-model review is still open.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate (category doc)JUnit 4 of 5 on main (-ea, -da), branch_10_6 and 10.5.2, 2 of 3 on 9.12.3 and 9.4.2; model grid 1,440 points, 448 differ from suggest(), 0 with the fix.
cross-model review, refute lens not real, low (procedural)OpenAI GPT-5.6, reading-only, could not run Java; agreed on the root cause and found the 10.0 migration note. A separate run re-ran the repro: 4 of 5 fail.
Independent reproducer realOwn JUnits fail 4 of 6 on main (-ea, -da) and 10.5.2, 3 of 5 on 9.12.3; 20 of 30 over 5 iterations; both controls pass.
Cross-model review, works-as-designed lens real, mediumOpenAI GPT-5.6, reading-only; rated the intended argument moderate and outweighed. Separate re-runs failed 4 of 5.
Intended-behaviour devil's advocate real, as a documentation and robustness bug (Low)Built the strongest intended case; the candidate JUnit fails on main and 10.5.2; its probe found the shortest trigger.
adjudication confirmed, highRe-ran the independent reproduction's JUnits with a new seed (-ea, -da): 4 of 6 fail, both controls pass.
Registration check and credibility audit keepThe finder's JUnit 4 of 5 on main (-da, two seeds); with the fix shadowed, only the direct-window test fails.
brief re-runs reproduced on 645d4fe14a20The repro through gradle and the standalone runner, assertions on and off, and on 10.5.2; the fix and the loud alternative with the suggest module and check.

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

Finding record

LFV-969, snapshot of 2026-11-06

Registry title
CompletionScorer silently ignores the BulkScorer [min, max) window instead of rejecting it, so a CompletionQuery run through IndexSearcher#search with a timeout (even one that never fires) or with intra-segment partitions returns repeated suggestions and drops others
Area
suggest / document (NRT) suggester: CompletionScorer under IndexSearcher's windowed execution (setTimeout, intra-segment partitions)
Priority
Low, rank 369: Needs the undocumented generic search path for CompletionQuery plus a timeout or intra-segment partitions.
Impact class
silent wrong results
Impact note
Only applications that execute a CompletionQuery through the generic IndexSearcher#search with a TopSuggestDocsCollector (a hand-written CollectorManager, or the search(Query, Collector) that SuggestIndexSearcher inherits), a path the experimental suggest module does not document, together with IndexSearcher#setTimeout (silent from 9.4.0) or intra-segment partitions (from 10.0) get duplicated and missing suggestions with no error; the documented SuggestIndexSearcher#suggest path, and the suggesters of Elasticsearch and OpenSearch, which copy its one-call-per-segment loop, are not affected; deterministic at ordinary scale once on that path.
Summary
You run a CompletionQuery through IndexSearcher#search with a TopSuggestDocsCollector (for example suggestSearcher.search(query, collector)) on a searcher that has a timeout set, even one that never fires, or that splits a segment into intra-segment partitions. You expect the same top suggestions as SuggestIndexSearcher#suggest, or a loud error if this path is unsupported, because BulkScorer#score must score only the documents in [min, max) and a timeout that never fires must not change results. Instead CompletionScorer runs its whole-segment top-N lookup again on every window or partition, so the top suggestions come back several times and lower ones are dropped (500 suggestions, n=5: [499, 499, 498, 498, 497] instead of [499, 498, 497, 496, 495]; one suggestion returned 5 times; partitions: [499 x5]), while the total says EQUAL_TO and timedOut() is false.
Introduced
be4d58c7740bea4e797eb1acff6355eb260e7c93 (trunk, 'LUCENE-6459: add common suggest API for document based NRT suggester', patch by Areek Zillur, committed by mikemccand; JIRA LUCENE-6459, migrated as apache/lucene#7518; no PR, SVN era), released through the branch_5x backport 380bb29f39595de1c3947075296d1d0a36f02fd8 [read: git blame and git log of CompletionScorer.java; exec: 5.3.0 fails, 5.2.1 has no such class]. It added CompletionScorer.score ignoring min and max and returning max; the logic is unchanged on main (d671dd8d890 LUCENE-6553 changed only the signature, 813ca77250d and 2cbf261032d are header and formatting). Not a regression. It became reachable through IndexSearcher#search later, without touching the suggest module: #927 (LUCENE-10151, setTimeout, 9.3.0: loud CollectionTerminatedException), #996 (9.4.0: the exception is caught, so the duplicates turn silent) and #13542 (intra-segment partitions, 10.0.0) (2015-05-28 (author and commit date of be4d58c7740 and 380bb29f395); exposure through IndexSearcher#search: 2022-06-29 (#927), 2022-07-04 (#996), 2024-09-10 (#13542))
First affected version
5.3.0 [exec] for the root defect (a direct Weight#bulkScorer + BulkScorer#score call with a window); through IndexSearcher#search silently from 9.4.0 [exec] for the timeout leg (9.3.0 throws CollectionTerminatedException instead [exec]) and from 10.0.0 [exec] for the intra-segment partition leg
Affected versions
Root defect: every release from 5.3.0 to 10.6.0 and main [exec: 5.3.0, 8.11.4, 9.2.0, 9.3.0, 9.4.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 4ae946e5a9b9, main a9779ef3c193; read: the CompletionScorer.score logic is unchanged since 5.3.0]. Silent wrong results under a timeout: 9.4.0 to 10.6.0 and main [exec: 9.4.0, 9.4.2, 9.12.3, 10.0.0, 10.5.2, branch_10_6 4ae946e5a9b9, main]. Silent wrong results with intra-segment partitions: 10.0.0 to 10.6.0 and main [exec: 10.0.0, 10.5.2, branch_10_6, main]. 9.3.0: the timeout leg throws CollectionTerminatedException [exec]. Before 9.3.0 IndexSearcher has no setTimeout and before 10.0.0 no partitions, so only a direct BulkScorer#score call shows it [exec: 9.2.0, 9.12.3 API probes]. Not fixed upstream: CompletionScorer.java unchanged since 2cbf261032dc (2021-01-05) on main 645d4fe14a20 (author date 2026-10-28), branch_11x, branch_10x and branch_10_6. 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
Upstream and prior reports
none found (the prior-report record, verdict new, searched 2026-10-28, read-only; 464 query records, 30 hits read): a scripted prior-report search with four specs (window/BulkScorer contract, timeout symptom, intra-segment partitions and TopSuggestDocs.merge, generic 'CompletionQuery through IndexSearcher'; GitHub, commits, downstream, dev@/java-user@/issues@/commits@, JIRA archive and 25 web queries) plus the finder's 51-query run; 67 manual GitHub searches over apache/lucene, Elasticsearch, OpenSearch, Solr, Lucene.NET and all of GitHub; 48 list and 11 JQL queries; about 40 issues and PRs read with all comments; the changed files of all 414 open apache/lucene PRs (only #16847, a mechanical int-to-long doc ID change in NRTSuggester and TopSuggestDocs*, touches suggest/document main code, not CompletionScorer); every issue and PR created or updated since 2026-10-26. Related, not this bug: #12032 (open: DrillSidewaysScorer fails loudly under QueryTimeout), #13753 (open: DrillSideways and intra-segment concurrency), #996 (its test comment names DrillSideways as the one scorer that needs the whole range), #13542 (added partitions and the MIGRATE.md note that Weights assuming one bulk scorer per segment per search 'need updating'; its body names DrillSideways as the only unsupported component), #7518 (LUCENE-6459, introducing issue, no discussion of windows), #12275 (open, setTimeout semantics). Refresh 2026-10-28 (GitHub search and REST API, read-only): 'CompletionScorer', 'CompletionQuery timeout', 'suggest setTimeout', 'TopSuggestDocsCollector', 'CompletionWeight' and 'suggest duplicate suggestions intra-segment' on apache/lucene find only the items above or unrelated ones; the 15 newest open PRs (to #16936, all in the follow-up's file-level scan) do not touch suggest; no new commit on CompletionScorer.java; upstream main head 645d4fe14a20. Gaps: GitHub search splits CamelCase names poorly (backed by phrase variants, the JIRA and list archives and the full open-PR file scan).
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28; runs of 2026-10-29) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 release jars. The hunt's earlier runs used main a9779ef3c193 (the files involved are identical), branch_10_6 4ae946e5a9b9, 10.5.2, 10.0.0, 9.12.3, 9.4.2, 9.4.0, 9.3.0, 9.2.0, 8.11.4, 5.3.0 and 5.2.1. The recommended fix and a loud alternative were validated in the scratch worktree: the repro test, 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/CompletionScorer.java:30 ('score(...) is called for each leaf reader'), :69-75 (score ignores min and max, runs suggester.lookup over the whole leaf, returns max), :89-91 (accept applies the filter and live docs); CompletionQuery.java:36-37 ('Use SuggestIndexSearcher#suggest(CompletionQuery, int, boolean) to execute any query'); lucene/core/src/java/org/apache/lucene/search/BulkScorer.java:42-43 ('All documents strictly before this value must be ignored') and :72-73 ('min Score starting at, including, this document', 'max Score up to, but not including, this doc'); lucene/MIGRATE.md:1678-1684 ('Weight#scorer, Weight#bulkScorer and Weight#scorerSupplier contract'); TimeLimitingBulkScorer.java:70-86 (growing windows); IndexSearcher.java:612 (setTimeout 'for all searches that run through this IndexSearcher'), :840-858 (searchLeaf), :1097-1100 (an entire-segment partition is [0, NO_MORE_DOCS)); TopSuggestDocs.java:115-132 (merge, no dedup across slices) [read by the finder, the independent reproduction, the intended-behaviour check and the registration check]
The finding in 30 seconds
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/document/CompletionScorer.java:30, (at 645d4fe14a20) :68-75 (score ignores min and max, returns max), :71, :89-92 (accept)
  • lucene/core/src/java/org/apache/lucene/search/BulkScorer.java:34-43, (at 645d4fe14a20) :72-73; lucene/core/src/java/org/apache/lucene/search/TimeLimitingBulkScorer.java:70-86; lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:612, :842-855, :1096-1100
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/document/CompletionQuery.java:36-37; (at 645d4fe14a20) SuggestIndexSearcher.java:41-42, :64-86; CompletionWeight.java:102-130, :149-153; TopSuggestDocsCollector.java:123-135; TopSuggestDocs.java:115-132; NRTSuggester.java:207
  • lucene/MIGRATE.md:1662-1666, (at 645d4fe14a20) :1668-1684
Who is affected
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/document/CompletionQuery.java:36-37; (at 645d4fe14a20) SuggestField.java:45-46
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/BulkScorer.java:34-43, (at 645d4fe14a20) :72-73; lucene/MIGRATE.md:1662-1666, :1668-1676, :1678-1684; a Lucene clone releases/lucene/10.0.0 lucene/MIGRATE.md:851-857
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:612; (at 645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:2568, :2580-2583, :2605, :2689-2691
  • lucene/facet/src/java/org/apache/lucene/facet/DrillSidewaysScorer.java:90-97; (at 645d4fe14a20) lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java:473
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/TimeLimitingBulkScorer.java:70-86; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/BulkScorer.java:38; lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:392-416 (partitions start at doc 0, :405); lucene/suggest/src/java/org/apache/lucene/search/suggest/document/TopSuggestDocsCollector.java:123-135; lucene/facet/src/java/org/apache/lucene/facet/DrillSidewaysScorer.java:90-97

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 at 645d4fe14a20 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.