All findings · Lucene finding · LFV-1003 · analyzingsuggester-num-times-analyzed-paths-int-overflow

AnalyzingSuggester: num x analyzed forms overflows an int, so with synonyms a large num silently returns fewer suggestions

With a synonym analyzer, AnalyzingSuggester and FuzzySuggester size their search queue as num x the most analyzed forms of any entry, in an int. With 3 forms, lookup("a", false, 1431655766) wraps that queue to 2 paths and silently returns 2 of the 3 matches; other values throw NoSuchElementException.

Summary

Priority
Low, rank 901The 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
silent wrong results
Since
4.1.0 (LUCENE-4481, 2012: the int product)introduced 2012-10-21 · over 14 years
Affected
4.1.0 to 10.5.2, 10.6.0 (released 2026-10-29) and main
Status
documented
Found by
a differential probe of every suggester at huge num, then an SMT bit-vector model of the wrap

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

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.

Background: how AnalyzingSuggester finds its suggestions

  1. 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.
  2. A synonym analyzer can give one entry several forms (ab, x, y); the suggester remembers the largest count of any entry, k.
  3. 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.
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].
The diagram follows the repro: three entries, one with 3 analyzed forms, and a lookup whose queue depth num x 3 wraps to 2, so one of the 3 matches is lost. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build an AnalyzingSuggester from ab (weight 1), ac (2) and ad (3) with a synonym analyzer that also indexes ab as x and y, so ab has 3 analyzed forms. Call lookup("a", false, 1431655766): num is documented only as the "maximum number of results to return".
  2. You expect all three, [ad, ac, ab], as num = 100 returns.
  3. 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 an int: 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.
  4. 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 own assert completions.isComplete fails, typically also at Integer.MAX_VALUE, where production results are right.
  5. It is in every release since 4.1.0 (2013), in 10.6.0 and main; FuzzySuggester inherits it. One-form analyzers and ordinary num values are not affected.

Who is 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.

The bug (main 645d4fe14a20)

  1. Contract. Lookup.lookup(key, onlyMorePopular, num): "@param num maximum number of results to return" (Lookup.java:190). AnalyzingSuggester.lookup checks only assert num > 0 (AnalyzingSuggester.java:670). Both classes are @lucene.experimental (:98; FuzzySuggester.java:64); FuzzySuggester extends AnalyzingSuggester and does not override lookup (FuzzySuggester.java:66).
  2. 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 pass maxGraphExpansions = -1, "no limit" (AnalyzingSuggester.java:170-195, :207-208; FuzzySuggester.java:98-127).
  3. The path count. "Highest number of analyzed paths we saw for any single input surface form" (AnalyzingSuggester.java:153-157), set by build as Math.max(maxAnalyzedPathsForOneInput, finiteStrings.size()) (:484) and stored with the suggester (:607, :618).
  4. The product, in int. new Util.TopNSearcher<>(fst, num - results.size(), num * maxAnalyzedPathsForOneInput, weightComparator) (:783-786). Java int arithmetic 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.
  5. Small positive depth. TopNSearcher keeps at most maxQueueDepth paths ("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 and lookup returns what is left.
  6. Depth 0. The first addIfCompetitive sees queue.size() == maxQueueDepth (0 == 0) and calls queue.last() on the empty TreeSet (Util.java:215-216): NoSuchElementException.
  7. Negative depth. The queue size never equals it, so nothing is pruned and the results are right; but search() returns isComplete = rejectCount + topN <= maxQueueDepth (Util.java:409), false, and AnalyzingSuggester.java:823-824 asserts it. If the search rejects a duplicate path, rejectCount + topN overflows too and the check passes, so the assert fires typically, not always.

The numbers (main, -da = production; -ea = assertions on)

entries; forms per entry (k)numnum x k as int-da-ea
ab/1 ac/2 ad/3; ab has 3100300[ad, ac, ab]not run (no wrap)
same1,431,655,7662[ad, ac], no errorAssertionError (:824)
8 entries luc... (weights 10-17); lucene has 31,431,655,7662[lucas, lucifer, lucid]: 3 of 8; skips luck (15), lucent (14), lucerne (13). FuzzySuggester: 5 of 8AssertionError
same; lucene has 42^300NoSuchElementException (Util.java:216)same
one 16-word entry, one synonym per word (2^16)65,5360NoSuchElementExceptionsame
8 entries; lucene has 2Integer.MAX_VALUE-2right 8AssertionError (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.lookup or FuzzySuggester.lookup whose index-time analyzer gives some entry 2 or more analyzed forms, with a num above Integer.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 NoSuchElementException from inside the FST search. With assertions on (tests): the class's own completeness assert, also at Integer.MAX_VALUE, the value Lucene's own TestFSTCompletion passes to mean "all results" on the separate FSTCompletion API (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 than getCount(), which for a graph analyzer counts analyzed paths, not entries, the hunt's LFV-431: the repro's 3 entries give getCount() = 5, so it is still a safe upper bound on the results), or bound k with maxGraphExpansions.
  • 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_VALUE is right in production. The NoSuchElementException leg is merely rare. No public report since 4.1.0 (2013).

Minimal repro

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.

TestAnalyzingSuggesterLargeNum.java (lucene/suggest, 1 test)55 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.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);
  }
}
Command
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.isComplete fails at AnalyzingSuggester.java:824.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the bug is plain Java int arithmetic)
Command (environment)
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

Observed output

# 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

Why it looks unintended

  • 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-466 states the rule for shortestPaths: "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 at AnalyzingSuggester.java:824 that 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"). AnalyzingSuggester is not such a caller: the same commit gave it assert completions.isComplete (:755, :824), while NRTSuggester, 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) maxAnalyzedPathsPerOutput and clamps it with Math.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?"; the long multiplication now there came from Robert Muir's error-prone "narrow calculation" change (#11923).
  • Tests. TestAnalyzingSuggester and TestFuzzySuggester use graph analyzers (for example testGraphDups) 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.

Suggested fix

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.

Multiply in long and saturate at Integer.MAX_VALUE (validated by the checks) (recommended)

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:409 adds rejectCount + topN in int too (a remark of the devil's advocate). With the saturated depth the flag comes out right; switching that sum to long as well would, with topN and the depth both at Integer.MAX_VALUE, report "incomplete" after one rejected duplicate although nothing was pruned (arithmetic, not executed). Changing TopNSearcher alone 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 similar int product, 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) and TestFuzzySuggester (14) pass.
  • The whole lucene/suggest module passes with assertions on (seed DEADBEEF) and off (seed 5EED1003): 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 long multiplication per lookup).

Fail loudly with Math.multiplyExact (not built)

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.

Clamp num to getCount() first (not built)

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.

Downstream note

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.

How it was found

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 Lookup implementations at growing num on the stock main jars: AnalyzingSuggester with a 2-form synonym analyzer threw AssertionError at Integer.MAX_VALUE with 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_VALUE makes isComplete false 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits; downstream trackers (Solr, Lucene.NET, OpenSearch, Elasticsearch)2026-10-28 to 2026-10-29three scripted prior-report search runs (59, 59 and 12 queries) plus the finder's run of 55, and 23 manual GitHub searches over five repositoriesno report; only the related items below
Open and recent PRs2026-10-28 to 2026-10-29the changed files of all 414 open apache/lucene PRs, and every issue and PR updated since 2026-10-27none touches the line
JIRA archive2026-10-298 JQL queries over LUCENE and SOLR, plus the tool's JIRA queriesno report
Mailing lists2026-10-29dev@, java-user@, issues@, commits@, solr-user@ and general@ through the tool, plus manual searchesno report
Web2026-10-298 web searches plus the tool's web queriesno report
GitHub (re-check)2026-10-31issues 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 commentsnothing new; line 786 unchanged

History

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).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 and a9779ef3c193 affectedthis 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 affectedthe finder's test fails; on 10.5.2 the 2^16-form trigger fails too
4.8.0, 8.11.4 affectedthe 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 onlythe 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 toothe 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 absentno AnalyzingSuggester (the port does not compile)
4.1.0 + saturating fix; main + fix not affectedevery case passes; on main the suggest module (32 suites, 237 tests) passes, assertions on and off

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
first signal (differential probe) flagged2-form synonym analyzer: AssertionError at Integer.MAX_VALUE with assertions on; the right 8 with assertions off.
finder candidateZ3 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 realOwn 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, mediumOpenAI 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, highRe-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 -> contrivedOn 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 verifiedThe one-method test fails on 645d4fe14a20 (-ea: the assert; -da: [ad, ac]) and passes with the fix.
doc re-runs repro and fix validatedGradle 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.

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

Finding record

LFV-1003, snapshot of 2026-11-06

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 whole lucene/suggest module with assertions on and off, :lucene:suggest:check -x test, the finder's 3-case test). The minimal repro also ran on 645d4fe14a20 through a standalone runner. The hunt and its checks ran on main a9779ef3c193 (lucene/suggest and Util.java identical), on 10.5.2, 9.12.3 and the 10.6 release candidate 4ae946e5a9b (the 10.6.0 tag), and public-API ports ran on 11 releases from 4.1.0 to 10.5.2.

Lucene and upstream sources

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, 165
  • SECURITY.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-824
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FuzzySuggester.java:59-62, (at 645d4fe14a20) :64, :66, :98-127
  • lucene/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) :165
  • lucene/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-466
  • lucene/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)

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

Repro files

About the diagrams

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.