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.
- The document suggester (
lucene/suggest) answers aCompletionQuerywith 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. - A
BulkScorerscores 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, orNO_MORE_DOCSwhen there is none. IndexSearcheruses windows in two cases. WithsetTimeoutit 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.
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.- Index 500 documents in one segment, one suggestion each:
apple0toapple499with weights 1 to 500.SuggestIndexSearcher#suggestfor the prefixapple, top 5, returns docs [499, 498, 497, 496, 495]. - Run the same
PrefixCompletionQuerythroughIndexSearcher#searchwith aTopSuggestDocsCollector(5, false)per slice: the same five. Now callsetTimeout(() -> false), a timeout that never fires. You expect the same five again. - 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.CompletionScorerignores the window and reruns the full top-5 lookup on every call, so window 2 adds 499 and 498 again until the queue overflows. - 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.
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.
- CompletionQuery (
PrefixCompletionQuery,RegexCompletionQuery,FuzzyCompletionQuery,ContextQuery): the queries of the document (NRT) suggester;CompletionWeight'sScorerSupplier#bulkScorer()returns aCompletionScorer(CompletionWeight.java:102-130). The module says: "Use SuggestIndexSearcher#suggest(CompletionQuery, int, boolean) to execute any query" (CompletionQuery.java:36-37). - BulkScorer window: "
minis 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 isNO_MORE_DOCS"if there are no more matches" (:34-40). - Timeout windows: with a
QueryTimeout,IndexSearcher#searchLeafwraps the scorer inTimeLimitingBulkScorer(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).
CompletionScorer.score(collector, acceptDocs, min, max)checks the collector type, callssuggester.lookup(this, acceptDocs, (TopSuggestDocsCollector) collector)and returnsmax(CompletionScorer.java:68-75).minandmaxare never read; the class javadoc still saysscore"is called for each leaf reader" (:30).- Under a timeout, the returned
maxsendsTimeLimitingBulkScoreron 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. TopSuggestDocsCollectorkeeps a top-n queue and throwsCollectionTerminatedExceptionat the first insert that overflows, on the assumption that every later hit of the segment scores lower (TopSuggestDocsCollector.java:123-135).searchLeafcatches it and moves on (IndexSearcher.java:853-855), so the search succeeds.- With partitions, each partition's call runs the whole lookup into its own collector, and
TopSuggestDocs.mergemerges them into a top n with no de-duplication and anEQUAL_TOtotal (TopSuggestDocs.java:115-132).
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).
- Needs: a
CompletionQueryexecuted throughIndexSearcher#searchwith aTopSuggestDocsCollector(a hand-writtenCollectorManager, or the deprecatedsearch(Query, Collector), whichSuggestIndexSearcherinherits), plus anyQueryTimeouton that searcher or intra-segment partitions. - The shortest trigger:
suggestSearcher.setTimeout(() -> false)and thensuggestSearcher.search(query, new TopSuggestDocsCollector(5, false)); on 20 suggestions it gives [19, 19, 18, 18, 17], whilesuggest()with the same timeout gives [19, 18, 17, 16, 15]. - Not affected:
SuggestIndexSearcher#suggest(SuggestIndexSearcher.java:69-76: onescore(..., 0, NO_MORE_DOCS)call per segment); the generic path without a timeout or partitions. Scope: on 10.x the generic path also throwsNullPointerExceptionon 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 whereCompletionWeighthands out aCompletionScorer, 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=truethe 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)throwsIllegalArgumentException("collector is not of type TopSuggestDocsCollector",CompletionScorer.java:71),count(q)throws,explainreturns null. - Side observation:
suggest()itself never appliessetTimeout; with an expired timeout it still returns every hit andtimedOut()stays false.
Exposure and likelihood170 words
- Who is hit. Code that executes completion suggestions like any other query: a
CollectorManagerofTopSuggestDocsCollector, or the inheritedsearch(Query, Collector)on aSuggestIndexSearcher, on a searcher that has a timeout or intra-segment partitions. A developer who wants to bound suggest latency has a reason to trysetTimeout, becausesuggest()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_TOtotal,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
NullPointerExceptionon any segment without the suggest field (LFV-967).
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.
/*
* 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);
}
}
}
}
# 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-eaand-da.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)BulkScorer#scoremust ignore documents beforeminand stop beforemax; 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
DrillSidewaysScorerthrows 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.maxis 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_DOCSis the value for "no more matches" (:34-40). - Migration note (10.0). "
Weightimplementations that rely on the assumption that a scorer, bulk scorer or scorer supplier for a givenLeafReaderContextis requested once per search need updating" (MIGRATE.md:1678-1684on main;:851-857in 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.
setTimeoutapplies "for all searches that run through this IndexSearcher" (IndexSearcher.java:612); the test framework'snewSearchersetssetTimeout(() -> 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 buildSuggestIndexSearcherdirectly and never meet it. - Precedent.
DrillSidewaysScorerrejects windows:"min must be 0, got " + minand"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
CompletionScoreror discussed the suggest module (#13542 only switchedSuggestIndexSearcher#suggestto the explicit whole-rangescore(..., 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.
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.
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 customslicesoverride 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] withtimedOut()true, the fix the same hits withtimedOut()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_DOCSis 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/suggestmodule passes with assertions on and off: 32 suites (31 upstream + the PR test), 240 tests, 7 skipped, 0 failures; the 6 upstreamsuggest.documentsuites (58 tests) among them. :lucene:suggest:check -x test(checkGoogleJavaFormat, ecjLint, forbiddenApis) passes aftertidyjoined 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.
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.
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.
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.
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.
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
IndexSearcherreturned 499 499 498 498 497 wheresuggest()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
CompletionScorerunder 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 xskipDuplicateson 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 toNO_MORE_DOCS, not tomaxDoc). 192 timeout points and 256 partition points differ fromsuggest(); 0 with the fix. - JUnit. Reduced to a JUnit (public API, plus a protected
IndexSearcher#slicesoverride for the partition test): 4 of 5 fail with assertions on and off.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs, review comments, commits and downstream trackers | 2026-10-28 | a 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 comments | no report; only the related items below |
| Open PRs | 2026-10-28 | the changed files of all 414 open apache/lucene PRs; everything created or updated since 2026-10-26 | none touches CompletionScorer.java; #16847 touches NRTSuggester and TopSuggestDocs mechanically |
| Mailing lists | 2026-10-28 | tool queries plus 48 manual list queries over dev@, java-user@, issues@ and commits@ | no report |
| JIRA archive | 2026-10-28 | tool queries and 11 JQL queries on LUCENE and SOLR | no report |
| Web | 2026-10-28 | 25 distinct web queries | no report |
| GitHub (re-check) | 2026-10-29 | search repo:apache/lucene CompletionScorer; suggest items updated since 2026-10-28; the file's latest upstream commit; the main head | only #9335, #14271, #7518 and #9334 (unrelated); nothing new; file unchanged since 2cbf261032dc (2021); upstream main 645d4fe14a20 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 and a9779ef3c193 | affected | 4 of 5 tests fail (gradle and the standalone runner, assertions on and off) |
branch_10_6 4ae946e5a9b9, 10.5.2, 10.0.0 | affected | 4 of 5 fail, the partition leg included |
| 9.12.3, 9.4.2, 9.4.0 | affected | silent copies under a timeout and the direct window call; no partitions API |
| 9.3.0 | fails loudly | the timeout tests throw CollectionTerminatedException; the direct window call is wrong |
| 9.2.0, 8.11.4, 5.3.0 | direct call only | no setTimeout; a direct score(c, null, 0, 100) collects 400 of 500 outside [0, 100) |
| 5.2.1 | API absent | no CompletionScorer |
| main + fix | not affected | the 3 search-level bug tests and the control pass; the suggest module (32 suites, 240 tests) passes, assertions on and off |
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.
| Check | Verdict | What 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 | real | Own 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, medium | OpenAI 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, high | Re-ran the independent reproduction's JUnits with a new seed (-ea, -da): 4 of 6 fail, both controls pass. |
| Registration check and credibility audit | keep | The 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 645d4fe14a20 | The 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 471 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,302 words
- 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 maina9779ef3c193(the files involved are identical),branch_10_64ae946e5a9b9, 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 wholelucene/suggestmodule with assertions on and off, and:lucene:suggest:check -x test.
- Finding record
a Lucene main checkout lucene/suggest/src/java/org/apache/lucene/search/suggest/document/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-1100lucene/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:207lucene/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-857lucene/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-2691lucene/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
- TestCompletionQueryUnderTimeout.java (JUnit test):
repro/LFV-969/src/org/apache/lucene/search/suggest/document/TestCompletionQueryUnderTimeout.java(53 lines)
It is made in parallel with this doc; every on-screen value comes from the code at 645d4fe14a20 or the diagram's executed probe.
assets/LFV-969/bug.png(471 KB)
