The suggester caps its FST search queue at num x k, where k is the most analyzed forms of any entry (3 when ab also indexes as x and y). The product is an int, so past Integer.MAX_VALUE it wraps: to a small cap that drops wanted paths, to 0, which throws, or below 0, which fails the completeness assert.
- At build time each entry goes through your index-time analyzer, and every analyzed form is stored in a weighted FST that maps the form to the entry.
- A synonym analyzer can give one entry several forms (
ab,x,y); the suggester remembers the largest count of any entry, k. - A lookup walks the FST best-first with a capped queue of candidate paths. Since one suggestion can appear once per form, the cap is num x k, so no wanted suggestion can be squeezed out.
- Build an
AnalyzingSuggesterfromab(weight 1),ac(2) andad(3) with a synonym analyzer that also indexesabasxandy, soabhas 3 analyzed forms. Calllookup("a", false, 1431655766): num is documented only as the "maximum number of results to return". - You expect all three,
[ad, ac, ab], as num = 100 returns. - Instead you get
[ad, ac], with no error. The FST search keeps num x 3 candidate paths, room for every form of each wanted suggestion, but multiplies in anint: 1,431,655,766 x 3 = 4,294,967,298 = 2^32 + 2, which wraps to 2, so the search throws away paths it still needs. - Other values wrap to 0 and throw
NoSuchElementException: num = 2^30 with 4 forms, or num = 65,536 when one 16-word entry has a synonym per word (2^16 forms). With assertions on, the class's ownassert completions.isCompletefails, typically also atInteger.MAX_VALUE, where production results are right. - It is in every release since 4.1.0 (2013), in 10.6.0 and main;
FuzzySuggesterinherits it. One-form analyzers and ordinary num values are not affected.
Applications whose suggester analyzer gives some entry 2 or more forms (synonyms, a token graph) and that pass a num above Integer.MAX_VALUE / k. Default constructor arguments; three entries suffice. A short list needs num in a narrow window, and Integer.MAX_VALUE is right in production, so it is contrived; one-form analyzers are not affected.
Mechanism, terms and numbers811 words
AnalyzingSuggester (and FuzzySuggester, which inherits lookup) finds the top num completions with a best-first search over its FST, Util.TopNSearcher, whose candidate queue is capped at maxQueueDepth. When the index-time analyzer turns one entry into several analyzed forms (synonyms or any token graph), a suggestion can be reached once per form, so the class sizes the queue as num * maxAnalyzedPathsForOneInput, the largest number of forms of any one entry: in Michael McCandless's words on LUCENE-4481 (#5547), "topN * M where M is the max number of analyzed forms we ever saw for any surface form". That product is a plain int (AnalyzingSuggester.java:786 on main 645d4fe14a20), and num is documented only as the "maximum number of results to return". With three entries and 3 forms on one of them, lookup("a", false, 1431655766) wraps the depth to 2 and silently returns 2 of the 3 matches; with the finder's 8 entries the same num returns 3 of 8 and skips three higher-weighted ones. A depth that wraps to 0 throws NoSuchElementException (num = 2^30 with 4 forms, or num = 65,536 with one 2^16-form entry), and a negative one keeps results right but fails the class's own assert completions.isComplete, typically at Integer.MAX_VALUE. The product is in every release from 4.1.0 to 10.6.0 and main. Multiplying in long and saturating at Integer.MAX_VALUE fixes it, validated with gradle on 645d4fe14a20.
- Contract.
Lookup.lookup(key, onlyMorePopular, num): "@param num maximum number of results to return" (Lookup.java:190).AnalyzingSuggester.lookupchecks onlyassert num > 0(AnalyzingSuggester.java:670). Both classes are@lucene.experimental(:98;FuzzySuggester.java:64);FuzzySuggester extends AnalyzingSuggesterand does not overridelookup(FuzzySuggester.java:66). - Graph analyzers are supported. "If SynonymFilter is used to map wifi and wireless network to hotspot then the partial text "wirele..." could suggest "wifi router"" (
AnalyzingSuggester.java:78-79); "At index time, complex analyzers can safely be used" (FuzzySuggester.java:59-62). The 3- and 4-argument constructors passmaxGraphExpansions = -1, "no limit" (AnalyzingSuggester.java:170-195,:207-208;FuzzySuggester.java:98-127). - The path count. "Highest number of analyzed paths we saw for any single input surface form" (
AnalyzingSuggester.java:153-157), set bybuildasMath.max(maxAnalyzedPathsForOneInput, finiteStrings.size())(:484) and stored with the suggester (:607,:618). - The product, in
int.new Util.TopNSearcher<>(fst, num - results.size(), num * maxAnalyzedPathsForOneInput, weightComparator)(:783-786). Javaintarithmetic wraps modulo 2^32: 1,431,655,766 x 3 = 4,294,967,298 is 2; 1,073,741,824 x 4 = 2^32 is 0; 2,147,483,647 x 2 is -2. - Small positive depth.
TopNSearcherkeeps at mostmaxQueueDepthpaths ("the maximum size of the queue of possible top entries",Util.java:186): once full, a new path that does not beat the worst is dropped, else it is added and the worst removed (Util.java:215-249). With room for 2, paths whose completions belong in the result are dropped andlookupreturns what is left. - Depth 0. The first
addIfCompetitiveseesqueue.size() == maxQueueDepth(0 == 0) and callsqueue.last()on the emptyTreeSet(Util.java:215-216):NoSuchElementException. - Negative depth. The queue size never equals it, so nothing is pruned and the results are right; but
search()returnsisComplete = rejectCount + topN <= maxQueueDepth(Util.java:409), false, andAnalyzingSuggester.java:823-824asserts it. If the search rejects a duplicate path,rejectCount + topNoverflows too and the check passes, so the assert fires typically, not always.
| entries; forms per entry (k) | num | num x k as int | -da | -ea |
|---|---|---|---|---|
ab/1 ac/2 ad/3; ab has 3 | 100 | 300 | [ad, ac, ab] | not run (no wrap) |
| same | 1,431,655,766 | 2 | [ad, ac], no error | AssertionError (:824) |
8 entries luc... (weights 10-17); lucene has 3 | 1,431,655,766 | 2 | [lucas, lucifer, lucid]: 3 of 8; skips luck (15), lucent (14), lucerne (13). FuzzySuggester: 5 of 8 | AssertionError |
same; lucene has 4 | 2^30 | 0 | NoSuchElementException (Util.java:216) | same |
| one 16-word entry, one synonym per word (2^16) | 65,536 | 0 | NoSuchElementException | same |
8 entries; lucene has 2 | Integer.MAX_VALUE | -2 | right 8 | AssertionError (typically) |
The short list keeps descending weight order; which items are missing, and how many, depends on the data and the FST layout. The arithmetic (a Z3 32-bit model and an exhaustive count over every positive int): with k = 2 the product is never a small non-negative number (only negative, so only the assertion); with k = 3 it is negative from num = 715,827,883 and equals 3 x num - 2^32 from 1,431,655,766 on, growing by 3 per step (205,032,704 at 1,500,000,000), so a short list needs num in a narrow window; with k = 4 exactly one num, 2^30, gives 0. The model also proved that num = Integer.MAX_VALUE makes isComplete false for every k from 2 to 2^20 when no path is rejected.
Exposure and likelihood289 words
- Who is hit. Callers of
AnalyzingSuggester.lookuporFuzzySuggester.lookupwhose index-time analyzer gives some entry 2 or more analyzed forms, with a num aboveInteger.MAX_VALUE / k. One-form analyzers (k = 1) and ordinary counts are not affected. No large dictionary is needed: three entries are enough for the short list. - Default or opt-in. Default constructor arguments (including
maxGraphExpansions = -1, no limit); the graph analyzer is the application's choice, but a documented use of the class. - What goes wrong. In production: at specific values, a silently incomplete list that can skip higher-weighted suggestions, or a
NoSuchElementExceptionfrom inside the FST search. With assertions on (tests): the class's own completeness assert, also atInteger.MAX_VALUE, the value Lucene's ownTestFSTCompletionpasses to mean "all results" on the separateFSTCompletionAPI (TestFSTCompletion.java:157,:165). - Scale. Normally num must be in the hundreds of millions to billions. But k has no default limit: one 16-word entry with one synonym per word has 2^16 forms, and num = 65,536 then throws in production.
- Workaround. Pass a finite num no larger than
Integer.MAX_VALUE / k(in practice no more thangetCount(), which for a graph analyzer counts analyzed paths, not entries, the hunt's LFV-431: the repro's 3 entries givegetCount()= 5, so it is still a safe upper bound on the results), or bound k withmaxGraphExpansions. - How likely: contrived for the short list (registry likelihood contrived, priority low): with 3 forms the wrapped depth is 3 x num - 2^32, so a list is short only for num within about (paths needed) / 3 of 1,431,655,766, and the natural
Integer.MAX_VALUEis right in production. TheNoSuchElementExceptionleg is merely rare. No public report since 4.1.0 (2013).
One JUnit method for lucene/suggest, public API only: an AnalyzingSuggester with a SynonymGraphFilter that gives ab 3 forms, entries ab, ac, ad, and lookup("a", false, 1431655766). Run it with assertions off to see the short list; with assertions on the class's own assert fails first. Fixed input, single thread.
/*
* 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.analyzing;
import java.io.StringReader;
import java.util.List;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.analysis.core.WhitespaceTokenizer;
import org.apache.lucene.analysis.synonym.SynonymGraphFilter;
import org.apache.lucene.analysis.synonym.SynonymMap;
import org.apache.lucene.search.suggest.FileDictionary;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.CharsRef;
public class TestAnalyzingSuggesterLargeNum extends LuceneTestCase {
public void testLargeNumReturnsAllMatches() throws Exception {
SynonymMap.Builder synonyms = new SynonymMap.Builder(true);
synonyms.add(new CharsRef("ab"), new CharsRef("x"), true);
synonyms.add(new CharsRef("ab"), new CharsRef("y"), true);
SynonymMap map = synonyms.build();
Analyzer analyzer =
new Analyzer() {
@Override
protected TokenStreamComponents createComponents(String fieldName) {
Tokenizer t = new WhitespaceTokenizer();
return new TokenStreamComponents(t, new SynonymGraphFilter(t, map, true));
}
};
AnalyzingSuggester suggester =
new AnalyzingSuggester(new ByteBuffersDirectory(), "suggest", analyzer);
suggester.build(new FileDictionary(new StringReader("ab\t1\nac\t2\nad\t3")).getEntryIterator());
List<String> keys =
suggester.lookup("a", false, 1431655766).stream().map(r -> r.key.toString()).toList();
// expected: all 3 matches; actual: [ad, ac], as the queue depth 1431655766 * 3 paths wraps to 2
assertEquals(List.of("ad", "ac", "ab"), keys);
}
}
cp $REPRO/TestAnalyzingSuggesterLargeNum.java $LUCENE/lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/
cd $LUCENE && ./gradlew :lucene:suggest:test --tests org.apache.lucene.search.suggest.analyzing.TestAnalyzingSuggesterLargeNum -Ptests.seed=DEADBEEF -Ptests.asserts=false
# with -Ptests.asserts=true (gradle's default) the class's own assert completions.isComplete fails first
- Expected
lookup("a", false, 1431655766)returns all three matches,[ad, ac, ab], as num = 100 does.- Observed
- It returns
[ad, ac]with no error (assertions off); with assertions on,assert completions.isCompletefails atAnalyzingSuggester.java:824.
More files, environment, full output285 words
TestAnalyzingSuggesterLargeNum (55 lines with the license header, one test method, package org.apache.lucene.search.suggest.analyzing like the upstream suggester tests; :lucene:suggest:check formats and lints it clean). It uses a ByteBuffersDirectory and a WhitespaceTokenizer (not MockTokenizer), so it is deterministic; LuceneTestCase still randomizes its environment (codec, locale, time zone).
Run with -Ptests.asserts=false to see the production result, [ad, ac]. With -Ptests.asserts=true, gradle's default, the class's own assert completions.isComplete (AnalyzingSuggester.java:824) fails before the test's assertEquals runs: the same wrapped depth, caught by Lucene itself.
The test keeps only the production-mode short list. With three entries the two returned are the top two by weight, so it does not show "skips higher-weighted ones" (the 8-entry table row does). The NoSuchElementException values, the 2^16-form trigger and the assertions-only Integer.MAX_VALUE case are left out on purpose: the same int product with the same fix. The same test is the suggested regression test; it passes with the fix.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT) |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the bug is plain Java int arithmetic) |
cp $REPRO/TestAnalyzingSuggesterLargeNum.java $LUCENE/lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/
cd $LUCENE && ./gradlew :lucene:suggest:test --tests org.apache.lucene.search.suggest.analyzing.TestAnalyzingSuggesterLargeNum -Ptests.seed=DEADBEEF -Ptests.asserts=false
# with -Ptests.asserts=true (gradle's default) the class's own assert completions.isComplete fails first
# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=false
TestAnalyzingSuggesterLargeNum > testLargeNumReturnsAllMatches FAILED
java.lang.AssertionError: expected:<[ad, ac, ab]> but was:<[ad, ac]>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:8DE7055CA230CA86]:0)
at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:120)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:146)
at org.apache.lucene.search.suggest.analyzing.TestAnalyzingSuggesterLargeNum.testLargeNumReturnsAllMatches(TestAnalyzingSuggesterLargeNum.java:53)
:lucene:suggest:test (FAILURE): 1 test, 1 failure
# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true
TestAnalyzingSuggesterLargeNum > testLargeNumReturnsAllMatches FAILED
java.lang.AssertionError
at __randomizedtesting.SeedInfo.seed([DEADBEEF:8DE7055CA230CA86]:0)
at org.apache.lucene.search.suggest.analyzing.AnalyzingSuggester.lookup(AnalyzingSuggester.java:824)
at org.apache.lucene.search.suggest.Lookup.lookup(Lookup.java:195)
at org.apache.lucene.search.suggest.analyzing.TestAnalyzingSuggesterLargeNum.testLargeNumReturnsAllMatches(TestAnalyzingSuggesterLargeNum.java:51)
:lucene:suggest:test (FAILURE): 1 test, 1 failure
# standalone runner, seed DEADBEEF, main 645d4fe14a20: -da and -ea, 1 run, 1 failed, the same two outcomes- num is the "maximum number of results to return" (
Lookup.java:190); nothing bounds it. - The cap is meant as a sound bound, "topN * M where M is the max number of analyzed forms" (Michael McCandless, LUCENE-4481); the class asserts its search was complete.
- A queue depth too small for the data was treated as a bug here before (LUCENE-4533).
The full argument372 words
- Contract. num is a maximum (
Lookup.java:190); nothing bounds it or says it is multiplied. - An admissible bound, by design. On LUCENE-4481 (#5547) Michael McCandless found the earlier topN-sized queue unsound for token graphs and planned to "bound the queue using topN * M where M is the max number of analyzed forms we ever saw for any surface form". A wrapped product is no longer that bound.
Util.java:465-466states the rule forshortestPaths: "All paths are kept, so we can pass topN for maxQueueDepth and the pruning is admissible". - The completeness assert is deliberate. On LUCENE-5519 (#6582) Robert Muir proposed returning a boolean saying whether the top-N is complete; Michael McCandless asked that callers "do the assert on that returned boolean; else, it means there's a serious bug (the search pruning is not admissible)" and suggested the name
isComplete; Simon Willnauer implemented and committed it (803a9907d39, 4.8.0). That is the assert atAnalyzingSuggester.java:824that fires here. LUCENE-5519 itself was opened to make depth enforcement optional for callers that accept an inexact top N (Simon Willnauer: "I am ok with a top N that is not 100% exact"; Robert Muir: "the current assert is bogus").AnalyzingSuggesteris not such a caller: the same commit gave itassert completions.isComplete(:755,:824), whileNRTSuggester, which does accept an inexact bound, says "search admissibility is not guaranteed" and leaves the assert commented out (NRTSuggester.java:257-259). - Precedent: a too-small depth was a bug. LUCENE-4533 (#5599, 2012), "if you pass dups (same surface form) to AnalyzingSuggester it can fail to return enough results": Michael McCandless's failing test "trips the assert in TopNSearcher that detects a too-small maxQueueDepth", and it was fixed.
- The neighbouring product is guarded. The document suggester computes
topN * (long) maxAnalyzedPathsPerOutputand clamps it withMath.min(NRTSuggester.java:294,:301). Reviewing that code (LUCENE-6339, #7400), Michael McCandless asked whether it "could possibly overflow int? Maybe use long, and then cast back to int after the Math.min?"; thelongmultiplication now there came from Robert Muir's error-prone "narrow calculation" change (#11923). - Tests.
TestAnalyzingSuggesterandTestFuzzySuggesteruse graph analyzers (for exampletestGraphDups) but only small num values; no test, javadoc or CHANGES entry accepts the wrap.
The devil's-advocate check173 words
The strongest "works as designed" case (first raised by the refute-lens reviewer from another model family, OpenAI GPT-5.6, then argued in full by the devil's advocate): no autosuggest UI asks for a billion suggestions; both classes are @lucene.experimental; maxGraphExpansions exists to bound k; TopNSearcher deliberately supports bounded queues that may give an inexact top N (LUCENE-5519); and at Integer.MAX_VALUE production results are right, so only a test-time assert fires.
Why it fails: that bears on likelihood and priority, not on correctness. No doc bounds num; the depth is meant to be a sound bound and the class asserts that it is (the inexact mode is for callers such as NRTSuggester that say so, not for AnalyzingSuggester); with the default maxGraphExpansions one heavily expanded entry makes num = 65,536 throw; @lucene.experimental is about API stability. The adjudication (confirmed, high confidence, priority low), the independent reproducer, the intended-lens reviewer and the devil's advocate treat it as a real defect; the refute-lens review's "not real" (low) was procedural, as it could not run Java.
Recommended Multiply in long and saturate at Integer.MAX_VALUE
Compute num x k in long and saturate it at Integer.MAX_VALUE before passing it as the queue depth (+5/-2 in AnalyzingSuggester.lookup), the pattern the document suggester already uses. Validated with gradle: the repro, TestAnalyzingSuggester, TestFuzzySuggester, the whole suggest module (237 tests) and check pass, with assertions on and off.
// AnalyzingSuggester.lookup, before the main TopNSearcher (line 783):
// Multiply in long and saturate: in int, num * maxAnalyzedPathsForOneInput wraps for a large
// num, and a wrapped queue depth makes the pruning inadmissible
int maxQueueDepth =
(int) Math.min((long) num * maxAnalyzedPathsForOneInput, Integer.MAX_VALUE);
Util.TopNSearcher<Pair<Long, BytesRef>> searcher;
searcher =
new Util.TopNSearcher<>(fst, num - results.size(), maxQueueDepth, weightComparator) {All options and trade-offs3 options · 607 words
One fix was built and validated; two alternatives were considered and not built, and one caveat concerns TopNSearcher itself.
Below Integer.MAX_VALUE / k nothing changes. Above it the depth is Integer.MAX_VALUE, a queue the search never fills, so it prunes nothing: what a negative wrap already does in production today, and admissible. isComplete (rejectCount + topN <= Integer.MAX_VALUE) is then true. It mirrors the document suggester's queue size (NRTSuggester.java:294, :301: a long product clamped with Math.min), the shape Michael McCandless suggested for that product on LUCENE-6339 (#7400): "use long, and then cast back to int after the Math.min".
// AnalyzingSuggester.lookup, before the main TopNSearcher (line 783):
// Multiply in long and saturate: in int, num * maxAnalyzedPathsForOneInput wraps for a large
// num, and a wrapped queue depth makes the pruning inadmissible
int maxQueueDepth =
(int) Math.min((long) num * maxAnalyzedPathsForOneInput, Integer.MAX_VALUE);
Util.TopNSearcher<Pair<Long, BytesRef>> searcher;
searcher =
new Util.TopNSearcher<>(fst, num - results.size(), maxQueueDepth, weightComparator) {Trade-offs
- No pruning above the threshold. For such a num the search keeps every candidate path, like an unbounded search; that is the cost of a correct answer for a huge num and the same as today's negative-wrap case. It does not make huge result counts cheap.
- The completeness sum.
Util.java:409addsrejectCount + topNininttoo (a remark of the devil's advocate). With the saturated depth the flag comes out right; switching that sum tolongas well would, with topN and the depth both atInteger.MAX_VALUE, report "incomplete" after one rejected duplicate although nothing was pruned (arithmetic, not executed). ChangingTopNSearcheralone does not fix this bug, since the wrap happens in the caller; nor is the clamp the only possible fix. - The exact-first searcher (
AnalyzingSuggester.java:729-735) has a similarintproduct,count * maxSurfaceFormsPerAnalyzedForm, which needs about 8.4 million exact-match prefix paths to wrap; a patch could saturate it too.
What the checks validated
- Fresh scratch worktree of
645d4fe14a20, gradle, OpenJDK 25: the repro fails on the stock tree and passes with the fix, with assertions on and off. TestAnalyzingSuggester(23 tests) andTestFuzzySuggester(14) pass.- The whole
lucene/suggestmodule passes with assertions on (seedDEADBEEF) and off (seed5EED1003): 32 suites, 237 tests, 7 skipped, 0 failures each time. :lucene:suggest:check -x test(checkGoogleJavaFormat, ecjLint, forbiddenApis) passes.- The finder's 3-case test (3 forms at 1,431,655,766; 4 forms at 2^30; 2 forms at
Integer.MAX_VALUE) passes 3 of 3 with assertions on and off. - Earlier, by the hunt (main
a9779ef3c193, same code): the same change passes the independent reproducer's 7 tests, the 2^16-form tests, the 4.1.0 port and 144 upstream suggest tests with all the hunt unit's fixes. - Not run: nightly tests, a benchmark (one
longmultiplication per lookup).
Throw ArithmeticException instead of returning a short list.
Trade-offs
Honest, but it rejects a legal request (num is only a maximum) that the saturating fix answers correctly.
num = (int) Math.min(num, getCount()) before sizing the search: no more than count suggestions can come back (duplicate surface forms are removed by the searcher's seen set). count is at least the number of entries; for a graph analyzer it counts analyzed paths (AnalyzingSuggester.java:419, :590, the hunt's LFV-431), so it is still a safe upper bound on the results.
Trade-offs
Keeps the depth small for small dictionaries, but count * k can still overflow for a large dictionary with many forms, so it still needs the long product; best as an addition to the saturating fix.
Solr passes the request parameter suggest.count unchanged to lookup as num, and its Analyzing and Fuzzy lookup factories default maxGraphExpansions to -1, so a Solr deployment with a synonym-expanded dictionary has to bound suggest.count itself.
A numeric-limits probe ran every suggester with growing num and flagged this class: with a 2-form synonym analyzer, num = Integer.MAX_VALUE failed its completeness assert. A 32-bit SMT model of the product then found the values that fail in production (1,431,655,766 with 3 forms, 2^30 with 4), and each was executed on the stock jars.
The method in full197 words
Method: non-formal first signal, then a small formal model (a hunt unit for suggester num sizes, 2026-10-28, main a9779ef3c193).
- First signal. A differential probe ran the
Lookupimplementations at growing num on the stock main jars:AnalyzingSuggesterwith a 2-form synonym analyzer threwAssertionErroratInteger.MAX_VALUEwith assertions on, where num = 1000 returns 8; with assertions off it returned the right 8. - Model. A Z3 32-bit bit-vector model of the queue-depth product found the production-mode witnesses the probe had not shown: the smallest num with a wrapped depth between 0 and topN is 1,431,655,766 for 3 forms (depth 2) and 2^30 for 4 forms (depth 0), and none exists for 2 forms; it proved that num =
Integer.MAX_VALUEmakesisCompletefalse for every k from 2 to 2^20 with no rejected path. An exhaustive count over every num followed. - Execution. Each witness ran on the stock jars, then became a JUnit; a probe over 1-4 forms x 14 num values on both suggesters failed only where the model predicted. An independent verifier later found a moderate trigger: one entry with 2^16 forms and num = 65,536. The minimal-repro step reduced it all to one method.
No prior report found (2026-10-29, rechecked 2026-10-31): GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream trackers and the web. Closest: LUCENE-4481 introduced the bound, LUCENE-5519 the assert, and in 2015 Michael McCandless asked whether the document suggester's similar product could overflow.
Searches6 searches
Related, not reports of this bug: #5547 (LUCENE-4481, 2012) introduced the product as an admissible bound, with no mention of overflow or a large num; #6582 (LUCENE-5519, 2014) added isComplete and the assert; #5599 (LUCENE-4533, fixed 2012) was a too-small depth caused by duplicate surface forms; #5546 (LUCENE-4480, fixed 2012) a different NoSuchElementException in TopNSearcher; in the LUCENE-6339 review (#7400, 2015) Michael McCandless asked whether the document suggester's own queue-size product could overflow int, a different class, guarded since (and multiplied in long since #11923); #970 (LUCENE-10607) is a different overflow in NRTSuggesterBuilder. Tracking issue #16937 has three comments (re-checked 2026-10-31), none about this bug: no maintainer triage. Gaps: GitHub search is token based and returns at most 50 items per query; inline PR review comments are not searchable; private reports cannot be seen.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits; downstream trackers (Solr, Lucene.NET, OpenSearch, Elasticsearch) | 2026-10-28 to 2026-10-29 | three scripted prior-report search runs (59, 59 and 12 queries) plus the finder's run of 55, and 23 manual GitHub searches over five repositories | no report; only the related items below |
| Open and recent PRs | 2026-10-28 to 2026-10-29 | the changed files of all 414 open apache/lucene PRs, and every issue and PR updated since 2026-10-27 | none touches the line |
| JIRA archive | 2026-10-29 | 8 JQL queries over LUCENE and SOLR, plus the tool's JIRA queries | no report |
| Mailing lists | 2026-10-29 | dev@, java-user@, issues@, commits@, solr-user@ and general@ through the tool, plus manual searches | no report |
| Web | 2026-10-29 | 8 web searches plus the tool's web queries | no report |
| GitHub (re-check) | 2026-10-31 | issues and PRs mentioning AnalyzingSuggester or suggester updated since 2026-10-29, maxAnalyzedPathsForOneInput, the newest commits on the files, the line on upstream main 2f424fd428da, the tracking issue's comments | nothing new; line 786 unchanged |
Introduced by LUCENE-4481 (dc49949558d, 2012-10-21), which gave the FST search its queue cap and this int product; first release 4.1.0 (2013). The assert that catches it in tests arrived in 4.8.0 (LUCENE-5519). Executed on 11 releases from 4.1.0 to 10.5.2, the 10.6.0 release candidate and main; unchanged on main.
Versions tested7 rows
Introduced: dc49949558d (trunk, svn r1400634; branch_4x 5726a78f9d4), 2012-10-21, LUCENE-4481 "add back some optos" (#5547; Michael McCandless; no PR, the Subversion era). It added TopNSearcher's maxQueueDepth, the maxAnalyzedPathsForOneInput field and the int product. The line has only been reformatted since.
Assert: 803a9907d39 (LUCENE-5519, #6582, Simon Willnauer, 2014-03-12, first in 4.8.0) added TopResults.isComplete and assert completions.isComplete; before 4.8.0 TopNSearcher only had a per-rejection assert rejectCount + topN <= maxQueueDepth (from dc49949558d), which fires only when a path is rejected; the executed cases reject none, so on 4.1.0 and 4.7.2 the short list comes back silently with assertions on too. 4.0.0 has no AnalyzingSuggester. Old releases ran on JDK 25 (the 6.0+ port uses NIOFSDirectory, as 6.0.0's MMapDirectory initializer fails on JDK 25, unrelated).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 and a9779ef3c193 | affected | this doc's repro fails (gradle, assertions on and off); the finder's, the reproducer's and the devil's advocate's tests fail |
10.6.0 (4ae946e5a9b), 10.5.2, 9.12.3 | affected | the finder's test fails; on 10.5.2 the 2^16-form trigger fails too |
| 4.8.0, 8.11.4 | affected | the public-API port, assertions off: 3 of 8 at 1,431,655,766 with 3 forms, 5 of 8 on FuzzySuggester, NoSuchElementException at 2^30 with 4 forms; assertions on: the assert fires |
| 4.10.4, 5.5.5, 6.0.0, 6.6.6, 7.7.3 | assertions on only | the port fails: the assert at 1,431,655,766 with 3 forms and at Integer.MAX_VALUE with 2, NoSuchElementException at 2^30 with 4 |
| 4.1.0, 4.7.2 | silent with -ea too | the same short lists and NoSuchElementException; no isComplete assert yet, and the older per-rejection assert does not fire here (no path is rejected), so with assertions on the short list comes back silently too |
| 4.0.0 | class absent | no AnalyzingSuggester (the port does not compile) |
| 4.1.0 + saturating fix; main + fix | not affected | every case passes; on main the suggest module (32 suites, 237 tests) passes, assertions on and off |
An independent reproducer, a devil's advocate, reading-only reviews by another model family (OpenAI GPT-5.6), an adjudication and a credibility audit agree on the root cause; the adjudication confirmed it with high confidence and the audit lowered the likelihood to contrived. This doc re-ran the one-method repro on 645d4fe14a20 and validated the fix with gradle.
Checks and verdicts408 words
The cross-model reviews were reading-only: they could not run Java. The refute-lens review returned a procedural "not real, low" while its static reading supported the mechanism and found the inputs in contract; a separate re-run failed 2 of 3 with assertions off.
| Check | Verdict | What it found |
|---|---|---|
| first signal (differential probe) | flagged | 2-form synonym analyzer: AssertionError at Integer.MAX_VALUE with assertions on; the right 8 with assertions off. |
| finder | candidate | Z3 witnesses and proof, an exhaustive count; 3-case JUnit fails 3 of 3 (-ea) and 2 of 3 (-da) on main, and with -ea on 10.5.2, 9.12.3 and the 10.6 release candidate; its fix passes 3 of 3. |
| cross-model review, refute lens | not real, low (procedural) | OpenAI GPT-5.6, reading-only; its reading supports the mechanism; raised the likelihood argument. A separate re-run failed 2 of 3. |
| Independent reproducer | real | Own test, own data (apple with 1-4 synonyms): fails 4 of 7 (-ea) and 3 of 7 (-da) on main, 10.5.2 and 9.12.3; controls pass; the fix passes 7 of 7; showed the -ea failure is typical, not universal. |
| Cross-model review, intended lens | real, medium | OpenAI GPT-5.6, reading-only; judged the works-as-designed case weak. A separate run re-ran the repro: 3 of 3 fail. |
| Intended-behaviour devil's advocate | real (Low) | Rejected misuse, a heuristic reading of the bound, @lucene.experimental and maxGraphExpansions as correctness arguments; found the 2^16-form trigger at num = 65,536 (main and 10.5.2). |
| adjudication | confirmed, high | Re-ran the independent reproduction's test with a new seed on a clean checkout: 4 of 7 (-ea) and 3 of 7 (-da) fail, the controls pass. |
| Registration check and credibility audit | downgrade (kept as a bug); likelihood rare -> contrived | On a clean checkout, seed DEADBEEF (registration check) and a fresh seed A0D17 (audit): 3 of 3 (-ea) and 2 of 3 (-da) fail; the audit narrowed the reach of the silent leg to a window near 2^32 / k. |
| minimal-repro step | verified | The one-method test fails on 645d4fe14a20 (-ea: the assert; -da: [ad, ac]) and passes with the fix. |
| doc re-runs | repro and fix validated | Gradle on a fresh scratch worktree: stock fails, fixed passes; TestAnalyzingSuggester, TestFuzzySuggester, the suggest module (237 tests) and check pass with the fix; the finder's 3 tests pass with it. |
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 478 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,394 words
- Registry title
- AnalyzingSuggester and FuzzySuggester compute the top-N queue depth num * maxAnalyzedPathsForOneInput in int, so with a synonym (token graph) analyzer a large num wraps it: lookup silently returns too few suggestions and skips higher-weighted ones, or throws NoSuchElementException, and with assertions on lookup(key, false, Integer.MAX_VALUE) typically fails 'assert completions.isComplete'
- Area
- suggest / analyzing: AnalyzingSuggester and FuzzySuggester lookup (FST top-N queue depth)
- Priority
- Low, rank 901: The analyzing and fuzzy suggesters' queue-depth int overflow only gives a silent short list for contrived num values near 2^32/k with a token-graph analyzer, while the natural Integer.MAX_VALUE works in production and only trips an -ea assertion, so it is weaker than LFV-387's MAX_VALUE silent drop.
- Impact class
- silent wrong results
- Impact note
- Applications that build an AnalyzingSuggester or FuzzySuggester with an index-time analyzer that gives some entry two or more analyzed paths (synonyms or another token graph) and call lookup with a num above Integer.MAX_VALUE / maxAnalyzedPathsForOneInput are exposed: in production (-da) only specific num values fail, with a silently short list (num 1431655766 with 3 paths returns 3 of 8) or a NoSuchElementException (when the product is a multiple of 2^32: num 2^30 with 4 paths, or num 65536 with one 2^16-path entry), while the natural 'all results' value Integer.MAX_VALUE returns the right list and only trips the class's own assertion under -ea; one-path analyzers and ordinary num values are not affected, and no large dictionary is needed.
- Summary
- You build an AnalyzingSuggester (or FuzzySuggester) whose index-time analyzer expands one entry into several analyzed forms, for example a SynonymGraphFilter that also indexes 'lucene' as 'search' and 'find', and call lookup("luc", false, num) with a large num; num is documented as the 'maximum number of results to return', so you expect the 8 suggestions that num = 1000 returns. Instead the search's queue depth num * maxAnalyzedPathsForOneInput is computed in int (AnalyzingSuggester.java:786) and wraps: with 3 paths and num = 1431655766 it becomes 2, the top-N search prunes paths it still needs, and lookup returns [lucas, lucifer, lucid] with no error, skipping the higher-weighted luck, lucent and lucerne (FuzzySuggester: 5 of 8), and with 4 paths and num = 2^30 it becomes 0 and lookup throws NoSuchElementException. With assertions enabled, lookup(key, false, Integer.MAX_VALUE) on a suggester with 2 or more paths typically fails 'assert completions.isComplete' (:824), although the same call without assertions returns the right list.
- Introduced
- dc49949558d776f3059896438b936d182f8dba33 (trunk, svn r1400634) and 5726a78f9d4ba5e8fb6f12b19cfa4c90bc15fdc0 (branch_4x, r1400635): LUCENE-4481 'add back some optos' (#5547; Michael McCandless; Subversion era, no pull request), which gave Util.TopNSearcher its maxQueueDepth and made AnalyzingSuggester pass num * maxAnalyzedPathsForOneInput as an int product; the line has only been reformatted since (3b67b174932, 2cbf261032d, dbaf97f5dcc). The -ea symptom comes from LUCENE-5519 (803a9907d39 trunk / 30fb5cacac2 branch_4x, 2014-03-12, Simon Willnauer, #6582, first in 4.8.0), which added TopResults.isComplete and 'assert completions.isComplete' [read: git log -S 'num * maxAnalyzedPathsForOneInput', git log -L, git blame and git show in the full-history clone; exec: 4.0.0 lacks the class, 4.1.0 reproduces] (2012-10-21 (the int product); 2014-03-12 (the isComplete assertion behind the -ea symptom))
- First affected version
- 4.1.0 [exec] (AnalyzingSuggester and FuzzySuggester first shipped in 4.1.0 with the product in place; 4.0.0 has no such class [exec: compile error]); the -ea assertion failure from 4.8.0 [exec]
- Affected versions
- Every release from 4.1.0 to 10.5.2 (129 releases), 10.6.0 (released 2026-10-29; run as its RC) and main [exec on 4.1.0 (-ea and -da), 4.7.2, 4.8.0 (-ea and -da), 4.10.4, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4 (-ea and -da), 9.12.3, 10.5.2 and main a9779ef3c193 with the follow-up's public-API ports; the finder's JUnit fails on 9.12.3, 10.5.2, the branch_10_6 RC 4ae946e5a9b and main; the independent reproduction's on 9.12.3, 10.5.2 and main; the intended-behaviour check's 2^16-path trigger on 10.5.2 and main]; the int product is in all 120 local release tags from 4.1.0 on (8.8.2-8.11.3 are not tagged locally and are inferred; 8.11.4 was executed) [read]. Without assertions every executed release gives the same outcomes (3 of 8 at num 1431655766 with 3 paths, 5 of 8 on FuzzySuggester, NoSuchElementException at 2^30 with 4 paths, the right 8 at Integer.MAX_VALUE with 2 paths); with -ea, 4.1.0-4.7.2 still return the short list silently (no isComplete assertion yet) and 4.8.0 on fail the assertion [exec]. Not fixed: upstream main 645d4fe14a20 (2026-10-29) still has line 786, and the newest commits on AnalyzingSuggester.java (54283150a81, 2026-06-14), FuzzySuggester.java (8ac26737913, 2021-08-25) and Util.java (378ca8d1bbf, 2026-01-15) do not touch it; no open PR changes it [read, recheck 2026-10-29]. Caveat: all runs used JDK 25.0.3 on Linux aarch64; the 6.0+ port uses NIOFSDirectory because 6.0.0's MMapDirectory initializer fails on JDK 25, which is unrelated
- 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; also reached by Z3, exhaustive check
- Upstream and prior reports
- none found (searched 2026-10-29, plus the finder's searches of 2026-10-28 and the independent reproduction's, the intended-behaviour check's and the adjudication's, read-only; the prior-report record, verdict new): three scripted prior-report searches (59, 59 and 12 queries over GitHub issues and PRs, commits, the Elasticsearch, OpenSearch and Solr trackers, the dev@, java-user@, issues@, commits@, solr-user@ and general@ lists, the JIRA archive and the web) plus the finder's run of 55, 23 manual GitHub searches over apache/lucene, apache/solr, apache/lucenenet, Elasticsearch and OpenSearch, all 414 open apache/lucene PRs (the finder checked every changed-file list; the follow-up re-checked the 50 most recent), recently closed PRs, 8 JQL queries over LUCENE and SOLR, manual list searches and 8 web searches; recheck 2026-10-29 (6 searches, every apache/lucene issue and PR updated since 2026-10-27, none on suggest, the newest commits on the three files, line 786 unchanged on upstream main 645d4fe14a20): nothing new. Related, not this bug: #5547 (LUCENE-4481, 2012) introduced the product as an admissible queue bound, with no mention of overflow or a large num; #6582 (LUCENE-5519, 2014) added isComplete and the assertion; #5546 (LUCENE-4480, fixed 2012) is a different NoSuchElementException in TopNSearcher (insertion into an exhausted queue); #5599 (LUCENE-4533, fixed 2012) is a too-small depth caused by duplicate surface forms; in the LUCENE-6339 review (#7400, 2015) Michael McCandless asked whether the document suggester's queue-size product 'could possibly overflow int', a different class whose product is guarded (Math.min, and since #11923 in 2022 a long multiplication), so the pattern was recognised there but never reported for this line; #970 (LUCENE-10607) is a different overflow in NRTSuggesterBuilder. Gaps: GitHub search is token based and returns at most 50 items per query, inline PR review comments are not searchable, the list archive cache holds only the start of each mail, and private reports cannot be seen.
- Lucene commit tested
- main
645d4fe14a20(commit of 2026-10-28; runs of 2026-10-31) through gradle in a fresh scratch worktree, with assertions on and off; the recommended fix was validated there (the repro,TestAnalyzingSuggester,TestFuzzySuggester, the wholelucene/suggestmodule with assertions on and off,:lucene:suggest:check -x test, the finder's 3-case test). The minimal repro also ran on645d4fe14a20through a standalone runner. The hunt and its checks ran on maina9779ef3c193(lucene/suggestandUtil.javaidentical), on 10.5.2, 9.12.3 and the 10.6 release candidate4ae946e5a9b(the 10.6.0 tag), and public-API ports ran on 11 releases from 4.1.0 to 10.5.2.
- Finding record
a Lucene main checkout: lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:484, 670, 783-787, 823-824; lucene/core/src/java/org/apache/lucene/util/fst/Util.java:215-216, 409; lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:190; lucene/suggest/src/java/org/apache/lucene/search/suggest/document/NRTSuggester.java:294; lucene/suggest/src/test/org/apache/lucene/search/suggest/fst/TestFSTCompletion.java:157, 165SECURITY.md lines 28, 38, 42, 46, 50
- The finding in 30 seconds
lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:184-196 (at 645d4fe14a20)lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:78-79, (at 645d4fe14a20) :98, :153-157, :170-195, :207-208, :484, :607, :618, :670, :783-786, :823-824lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FuzzySuggester.java:59-62, (at 645d4fe14a20) :64, :66, :98-127lucene/core/src/java/org/apache/lucene/util/fst/Util.java:186, (at 645d4fe14a20) :215-249, :409, :435-436
- Who is affected
lucene/suggest/src/test/org/apache/lucene/search/suggest/fst/TestFSTCompletion.java:157, (at 645d4fe14a20) :165lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:170-195, (at 645d4fe14a20) :207-208; lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FuzzySuggester.java:98-127
- Why it looks unintended
lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:190; (at 645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:670, :824; lucene/core/src/java/org/apache/lucene/util/fst/Util.java:465-466lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/TestAnalyzingSuggester.java:338, (at 645d4fe14a20) :427, :551, :870; TestFuzzySuggester.java:426, :738
- Suggested fix
lucene/core/src/java/org/apache/lucene/util/fst/Util.java:409; (at 645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:729-735, :787-797, :851; lucene/suggest/src/java/org/apache/lucene/search/suggest/document/NRTSuggester.java:294, :301
- History
a Lucene clone (releases/lucene/10.6.0 -> 4ae946e5a9b9: AnalyzingSuggester.java:786, :824)
- TestAnalyzingSuggesterLargeNum.java (lucene/suggest, 1 test):
repro/LFV-1003/src/org/apache/lucene/search/suggest/analyzing/TestAnalyzingSuggesterLargeNum.java(55 lines)
assets/LFV-1003/bug.png(478 KB)
![Diagram of the AnalyzingSuggester int overflow: entries ab, ac and ad, with ab indexed as ab, x and y (3 analyzed forms); lookup("a", false, 1431655766) sizes its search queue as 1,431,655,766 x 3 = 4,294,967,298, which wraps in an int to 2, and returns [ad, ac] instead of [ad, ac, ab].](../assets/LFV-1003/bug.png)