All findings · Lucene finding · LFV-998 · blendedinfix-num-times-numfactor-int-overflow

BlendedInfixSuggester: num x numFactor overflows an int, so asking for all suggestions throws and some large num values return fewer

BlendedInfixSuggester.lookup("luc", false, Integer.MAX_VALUE) throws IllegalArgumentException: numHits must be > 0, even with one entry: the suggester fetches num x numFactor (10) hits, computed in an int, and 2,147,483,647 x 10 wraps to -10. Other large values wrap to small counts: with 8 matches, num = 429,496,731 silently returns 1 suggestion.

Summary

Priority
Low, rank 655BlendedInfixSuggester's int num * numFactor wrap is the same class as the SpellChecker 10 * numSug row LFV-996 (mostly a misleading IllegalArgumentException at Integer.MAX_VALUE, silent truncation only at specific huge num values) in the opt-in suggest module.
Impact
wrong error
Since
4.7.0 (LUCENE-5354, 2014: the class shipped with the int product)introduced 2014-01-09 · over 12 years
Affected
4.7.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

To re-rank by where the typed word sits, BlendedInfixSuggester asks its parent for num x numFactor hits and later divides that count by numFactor to get num back. The product is an int: above Integer.MAX_VALUE it wraps, to a negative count, which the collector rejects, or to a small one, which keeps too few results.

Background: how BlendedInfixSuggester picks its suggestions

  1. AnalyzingInfixSuggester finds the entries with a word that starts with what the user typed and returns the num with the highest weight.
  2. BlendedInfixSuggester extends it and favours entries whose matched word comes early, which can change the order. So it over-fetches: it asks the parent for num x numFactor hits by weight (numFactor 10 by default).
  3. It lowers each hit's weight by the matched word's position and keeps the best count / numFactor, where count is the number it asked the parent for: normally exactly num.
Diagram of the BlendedInfixSuggester int overflow: lookup with num Integer.MAX_VALUE multiplies by numFactor 10, the int product wraps to -10 and the parent's collector throws numHits must be > 0; with 8 entries, num 429,496,731 wraps to 14, 14 divided by 10 is 1, and one of the 8 suggestions comes back; the fix multiplies in long and saturates.
The diagram follows the repro: a one-entry suggester asked for Integer.MAX_VALUE suggestions multiplies num by numFactor 10 in an int, gets -10 and throws; then, with 8 entries, how a larger num returns fewer (429,496,731 wraps to 14, 14 / 10 = 1 suggestion); then the saturating fix. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build a BlendedInfixSuggester with the default constructor from one entry, lucene, and ask for every completion of luc: lookup("luc", false, Integer.MAX_VALUE). num is documented only as the "maximum number of results to return".
  2. You expect [lucene], the same as num = 1000 returns.
  3. Instead it throws IllegalArgumentException: numHits must be > 0; please use TotalHitCountCollector if you just need the total hit count. To re-rank by word position the suggester first fetches num x numFactor hits (numFactor is 10 by default), computed in an int: 2,147,483,647 x 10 = 21,474,836,470, which wraps to -10.
  4. Other large values wrap to a small positive count, which the suggester divides by numFactor to get back "the num initially requested". With 8 matching entries, num = 429,496,731 wraps to 14, and 14 / 10 = 1: one of the 8 suggestions, no error. num = 429,496,730 wraps to 4; with 4 / 10 = 0 it throws NoSuchElementException.
  5. Every num above Integer.MAX_VALUE / numFactor wraps: from 214,748,365 with the default factor, from 2,147,484 with numFactor 1000. It is as old as the class (4.7.0, 2014) and is in 10.6.0 and main.

Who is affected

Applications that call BlendedInfixSuggester.lookup with a num above Integer.MAX_VALUE / numFactor (214,748,364 for the default 10), for example Integer.MAX_VALUE for "all suggestions" or a count passed through from a request. Default constructor, any built suggester (executed with one entry). Ordinary counts are unaffected; rare.

Mechanism, terms and numbers788 words

BlendedInfixSuggester over-fetches: to return num suggestions it asks its parent AnalyzingInfixSuggester for num * numFactor hits (numFactor is 10 by default), re-scores them by the position of the matched word, and divides the count back ("we reduce the num to the one initially requested", int actualNum = num / numFactor). The multiplication is a plain int product (BlendedInfixSuggester.java:199 on main 645d4fe14a20), so for num > Integer.MAX_VALUE / numFactor (214,748,364 for the default factor) it wraps. lookup("luc", false, Integer.MAX_VALUE), a natural way to ask for every suggestion, then fails on any built suggester (executed with one entry) with IllegalArgumentException: numHits must be > 0, a message about an argument the caller never passed. At other values the wrapped count is small and positive and the lookup silently returns too few results: with 8 matching entries, num = 429,496,731 returns 1, while num = 1000 returns all 8; num = 429,496,730 throws NoSuchElementException. On stock code the same huge values that do not wrap fail with OutOfMemoryError in the parent's collector pre-size (the sibling bug LFV-1004), so the short list is the correctness impact (at Integer.MAX_VALUE stock code fails either way) and returning every match needs both fixes: a saturating long product here and a collector cap at maxDoc there, validated together. The defect is as old as the class (LUCENE-5354, 4.7.0, 2014) and is in every release up to 10.6.0 and main.

The bug (main 645d4fe14a20)

  1. Contract. Lookup.lookup(key, onlyMorePopular, num): "@param num maximum number of results to return" (Lookup.java:190; also :206, :219). No bound and no check. The class is @lucene.experimental (BlendedInfixSuggester.java:54); its javadoc says "it increases the number of elements searched and applies the ponderation after" (:49-52). DEFAULT_NUM_FACTOR = 10 (:64, set at :90); the two longer constructors take numFactor unchecked (:114, :152).
  2. The product, once, in int. Three lookup overloads pass num on unchanged ("Don't * numFactor here since we do it down below, once, in the call chain", :162, :174, :186). The last one multiplies: "We need to do num * numFactor here only because it is the last call in the lookup chain" and return super.lookup(key, contextQuery, num * numFactor, allTermsRequired, doHighlight); (:198-199). Java int arithmetic wraps modulo 2^32: 2,147,483,647 x 10 = 21,474,836,470 is -10; 429,496,731 x 10 = 4,294,967,310 = 2^32 + 14 is 14.
  3. Negative count. The parent builds new TopFieldCollectorManager(SORT, num, null, 1) with the wrapped value (AnalyzingInfixSuggester.java:739), which throws for numHits <= 0 (TopFieldCollectorManager.java:62-65).
  4. Small positive count. The search runs and the parent hands the same wrapped value to createResults (AnalyzingInfixSuggester.java:745). The override divides it back: "we reduce the num to the one initially requested", int actualNum = num / numFactor; (BlendedInfixSuggester.java:226-227), which restores the caller's num only when the product did not wrap. boundedTreeAdd keeps at most actualNum results (:281, :294-306); with actualNum = 0 the check results.size() >= num is true on the empty set and results.first() (:297-298) throws NoSuchElementException.
  5. Never more, never outside the filter. A wrapped product is always below the true one, so the list can only get shorter. The context filter is part of the query (AnalyzingInfixSuggester.java:692-715), independent of num.

The numbers (8 entries matching luc, default numFactor 10; main, assertions on and off)

lookup("luc", false, num)num * 10 as an intkept (/ 10)result
1,00010,0001,000all 8 suggestions
214,748,365 (first value that wraps)-2,147,483,646-IllegalArgumentException: numHits must be > 0
429,496,73040NoSuchElementException
429,496,731141[lucas]: 1 of 8, no error
429,496,732242[lucas, lucifer]: 2 of 8, no error
Integer.MAX_VALUE-10-IllegalArgumentException: numHits must be > 0

With numFactor = 1000 it starts 100 times earlier: 2,147,484 throws the IllegalArgumentException, 4,294,968 the NoSuchElementException, and 4,294,969 returns [lucas]. Over every positive int (numFactor 10, 8 matches), 1,073,741,824 values (half of them) throw the IllegalArgumentException, 4 the NoSuchElementException, and 28 return a short list; with numFactor 1000, 3,493 values return a short list. By the same arithmetic the short-list windows widen with more matches (the count above fixed 8).

Tied to LFV-1004

On stock code the parent pre-sizes its collector by the fetch count (LFV-1004, suggester-lookup-presizes-by-num), so huge values that do not wrap fail too: with a 2 GB heap, num = 214,748,364 (product 2,147,483,640) throws OutOfMemoryError: Java heap space in the parent and in the blended class alike. The wrap turns that loud failure into a misleading exception or a short list. Each fix alone leaves Integer.MAX_VALUE failing; together they return every match.

Exposure and likelihood257 words
  • Who is hit. Callers of lookup on a BlendedInfixSuggester with num above Integer.MAX_VALUE / numFactor: 214,748,364 for the default factor, 2,147,483 for numFactor 1000, 3,579,139 for numFactor 600. For example Integer.MAX_VALUE as "all suggestions" (the module's own TestFSTCompletion uses it that way on the separate FSTCompletion API, TestFSTCompletion.java:157, :165), or a count forwarded from a request without a bound.
  • Default or opt-in. The default constructor and default numFactor; the class itself is an opt-in, @lucene.experimental suggester. Counts in the tens to thousands are not affected.
  • Effect. At Integer.MAX_VALUE every lookup on a built suggester fails (the collector is created before the search, AnalyzingInfixSuggester.java:739; executed with one entry), with an IllegalArgumentException that blames numHits and suggests TotalHitCountCollector, neither of which the caller used. At specific values in the hundreds of millions (millions with a large numFactor) the lookup silently returns fewer suggestions than exist, and a larger num can return fewer than a smaller one. That short list is the correctness impact: on stock code Integer.MAX_VALUE fails anyway (LFV-1004), so there the wrap swaps one error for another.
  • Workaround. Pass a finite count no larger than Integer.MAX_VALUE / numFactor; with LFV-1004 also present, small enough for the collector the parent pre-allocates (in practice the number of entries, getCount()).
  • How likely: rare (registry likelihood rare, priority low). The natural trigger fails loudly on the first call, so developers meet it in testing and pick a finite count; the silent values are narrow windows; the recorded searches found no public report since 4.7.0.

Minimal repro

One JUnit method for lucene/suggest, public API only: a default BlendedInfixSuggester with one entry, lucene, and lookup("luc", false, Integer.MAX_VALUE). It expects the one suggestion and gets the IllegalArgumentException. Fixed input, single thread, under a second.

TestBlendedInfixSuggesterNumOverflow.java (lucene/suggest, 1 test)46 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.IOException;
import java.io.StringReader;
import java.util.List;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.search.spell.PlainTextDictionary;
import org.apache.lucene.search.suggest.Lookup.LookupResult;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestBlendedInfixSuggesterNumOverflow extends LuceneTestCase {

  public void testLookupWithIntegerMaxValueNum() throws IOException {
    try (Analyzer analyzer = new StandardAnalyzer();
        BlendedInfixSuggester suggester =
            new BlendedInfixSuggester(new ByteBuffersDirectory(), analyzer)) {
      suggester.build(new PlainTextDictionary(new StringReader("lucene")));
      List<LookupResult> results;
      try {
        results = suggester.lookup("luc", false, Integer.MAX_VALUE);
      } catch (IllegalArgumentException e) {
        // expected the 1 suggestion; actual: num * numFactor (10) wraps to -10 and is rejected
        throw new AssertionError("expected 1 suggestion for num=Integer.MAX_VALUE, got " + e, e);
      }
      assertEquals(1, results.size());
    }
  }
}
Command
cp $REPRO/TestBlendedInfixSuggesterNumOverflow.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.TestBlendedInfixSuggesterNumOverflow -Ptests.seed=DEADBEEF -Ptests.asserts=true
# with -Ptests.asserts=false it fails the same way
Expected
lookup("luc", false, Integer.MAX_VALUE) returns the one suggestion, lucene.
Observed
It throws IllegalArgumentException: numHits must be > 0; please use TotalHitCountCollector if you just need the total hit count, raised for the wrapped count from BlendedInfixSuggester.java:199.

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

More files, environment, full output302 words

TestBlendedInfixSuggesterNumOverflow (46 lines with the license header, one test method, package org.apache.lucene.search.suggest.analyzing like the upstream suggester tests). The try/catch only turns the exception into a message that states expected vs actual; without it the test fails the same way with the raw IllegalArgumentException. LuceneTestCase still randomizes its environment (codec, locale, time zone); the test failed with every seed tried (DEADBEEF, and 5EED1234 five times), with assertions on and off.

The silent short list (num = 429,496,731 returns 1 of 8) is not in the test on purpose: it is the same int product at other values with the same fix, and it needs 8 entries and a num chosen to land in a window. The same test is the suggested regression test; it passes with the fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 and 10.5.2 release jars
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/TestBlendedInfixSuggesterNumOverflow.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.TestBlendedInfixSuggesterNumOverflow -Ptests.seed=DEADBEEF -Ptests.asserts=true
# with -Ptests.asserts=false it fails the same way

Observed output

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true
TestBlendedInfixSuggesterNumOverflow > testLookupWithIntegerMaxValueNum FAILED
    java.lang.AssertionError: expected 1 suggestion for num=Integer.MAX_VALUE, got java.lang.IllegalArgumentException: numHits must be > 0; please use TotalHitCountCollector if you just need the total hit count
        at org.apache.lucene.search.suggest.analyzing.TestBlendedInfixSuggesterNumOverflow.testLookupWithIntegerMaxValueNum(TestBlendedInfixSuggesterNumOverflow.java:41)
        Caused by:
        java.lang.IllegalArgumentException: numHits must be > 0; please use TotalHitCountCollector if you just need the total hit count
            at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.TopFieldCollectorManager.<init>(TopFieldCollectorManager.java:63)
            at org.apache.lucene.search.suggest.analyzing.AnalyzingInfixSuggester.lookup(AnalyzingInfixSuggester.java:739)
            at org.apache.lucene.search.suggest.analyzing.BlendedInfixSuggester.lookup(BlendedInfixSuggester.java:199)
            at org.apache.lucene.search.suggest.analyzing.AnalyzingInfixSuggester.lookup(AnalyzingInfixSuggester.java:536)
            at org.apache.lucene.search.suggest.analyzing.BlendedInfixSuggester.lookup(BlendedInfixSuggester.java:175)
            at org.apache.lucene.search.suggest.analyzing.AnalyzingInfixSuggester.lookup(AnalyzingInfixSuggester.java:516)
            at org.apache.lucene.search.suggest.analyzing.BlendedInfixSuggester.lookup(BlendedInfixSuggester.java:163)
            at org.apache.lucene.search.suggest.Lookup.lookup(Lookup.java:195)
            at org.apache.lucene.search.suggest.analyzing.TestBlendedInfixSuggesterNumOverflow.testLookupWithIntegerMaxValueNum(TestBlendedInfixSuggesterNumOverflow.java:38)
:lucene:suggest:test (FAILURE): 1 test, 1  failure
# the seed frame is left out; with -Ptests.asserts=false gradle fails the same way
# standalone runner, seed DEADBEEF: main -ea and -da, 10.6.0 -da, 10.5.2 -da: 1 run, 1 failed, the same exception

Why it looks unintended

  • num is the "maximum number of results to return" (Lookup.java:190); nothing bounds it.
  • The code says the division restores "the num initially requested"; after a wrap it does not, so a larger num can return fewer: 1,000 gives 8, 429,496,731 gives 1.
  • SOLR-7865 already fixed a count that did not match num in this very expression.
The full argument234 words
  • Contract. num is a maximum (Lookup.java:190); the BooleanQuery overload says "@param num number of items to return" (AnalyzingInfixSuggester.java:617). Nothing bounds it or says the subclass multiplies it.
  • The code's own intent. "we reduce the num to the one initially requested" (BlendedInfixSuggester.java:226) holds only without overflow.
  • A positive num must not produce these errors. The IllegalArgumentException message is false for the caller's input, the NoSuchElementException is undocumented, and a larger num can return fewer results (1,000 gives 8, 429,496,731 gives 1), with the count jumping between nearby values (429,496,730 throws, 429,496,732 gives 2); a top-N lookup should not shrink when N grows.
  • History: a count that does not match num was a bug here. SOLR-7865 ("BlendedInfixSuggester was returning more results than requested", Arcadius Ahouansou's patch, committed by Michael McCandless as 602a1f50e72, 2015) fixed the opposite symptom, num * numFactor applied several times along the call chain, and left one multiplication, still in int: today's line 199.
  • History: overflow in this class's arithmetic was a bug. On LUCENE-8343 (#9390) Adrien Grand asked of the score arithmetic "It might even introduce overflows?", and Alessandro Benedetti fixed it (e0232f10450, 2018, "weight long overflow fix + test"). Reviewing LUCENE-6339 (#7400), Michael McCandless asked of a suggester's queue-size product whether it "could possibly overflow int? Maybe use long".
  • Tests. TestBlendedInfixSuggester uses num up to 10 only; no test accepts the wrap.
The devil's-advocate check194 words

The strongest "works as designed" case (first raised by the finder's refute-lens reviewer from another model family, OpenAI GPT-5.6, then argued by the intended-lens reviewer and the devil's advocate): num is only a maximum; a num in the hundreds of millions is unreasonable for a suggester; the class is @lucene.experimental and warns that it "might be costly"; the small-num design is explicit in LUCENE-5354 (Remi Melisson: "If I want 10 suggestions, then I search against the current ordered index for the 100 first results"); and on stock code the parent already throws OutOfMemoryError at these values.

Why it fails: that bears on likelihood and priority, not on correctness. No doc bounds num or numFactor; a design for small num does not make a false error message or a wrong count acceptable for a large one; @lucene.experimental is about API stability; and once the parent's pre-size is fixed (LFV-1004), the wrap is the only thing that still fails. The adjudication (confirmed, high confidence, priority low), the independent reproduction, the cross-model review and the intended-behaviour check 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, plus LFV-1004's collector cap

Compute num x numFactor in long and saturate it at Integer.MAX_VALUE (+3/-1 in BlendedInfixSuggester.lookup), together with LFV-1004's collector cap at maxDoc (+3/-1): each alone still fails at Integer.MAX_VALUE. Validated with gradle: the repro passes, and the whole suggest module (237 tests) and check pass, with assertions on and off.

// BlendedInfixSuggester.lookup(key, contextQuery, num, ...), line 199:
// Multiply in long and saturate: in int, num * numFactor wraps once num > MAX_VALUE / numFactor
int fetch = (int) Math.min((long) num * numFactor, Integer.MAX_VALUE);
return super.lookup(key, contextQuery, fetch, allTermsRequired, doHighlight);

// AnalyzingInfixSuggester.lookup, line 739 (LFV-1004's fix):
int numHits = Math.min(num, Math.max(1, searcher.getIndexReader().maxDoc()));
TopFieldCollectorManager c = new TopFieldCollectorManager(SORT, numHits, null, 1);
All options and trade-offs4 options · 700 words

One fix was built and validated (two sites, together with LFV-1004's fix); three alternatives were considered and not built.

Multiply in long and saturate at Integer.MAX_VALUE, plus LFV-1004's collector cap (validated by the checks) (recommended)

Past the threshold the fetch count becomes Integer.MAX_VALUE, so createResults keeps Integer.MAX_VALUE / numFactor results (214,748,364 for the default) instead of a wrapped count, and every larger num behaves like num = 214,748,364. Below the threshold nothing changes. The parent still hands the uncapped count to createResults (AnalyzingInfixSuggester.java:745); only the collector is capped. It is the end state of the neighbouring queue-size product in NRTSuggester (#11923 made that product long; its Math.min saturation was already there), and the fix recommended for LFV-999.

// BlendedInfixSuggester.lookup(key, contextQuery, num, ...), line 199:
// Multiply in long and saturate: in int, num * numFactor wraps once num > MAX_VALUE / numFactor
int fetch = (int) Math.min((long) num * numFactor, Integer.MAX_VALUE);
return super.lookup(key, contextQuery, fetch, allTermsRequired, doHighlight);

// AnalyzingInfixSuggester.lookup, line 739 (LFV-1004's fix):
int numHits = Math.min(num, Math.max(1, searcher.getIndexReader().maxDoc()));
TopFieldCollectorManager c = new TopFieldCollectorManager(SORT, numHits, null, 1);

Trade-offs

  • Both parts are needed for Integer.MAX_VALUE. Saturation alone sends 2,147,483,647 to the parent, which pre-sizes its collector and throws OutOfMemoryError: Requested array size exceeds VM limit (executed, standalone, 512 MB heap). The cap alone gives min(-10, maxDoc) = -10 and the same IllegalArgumentException (executed). So this fix goes with LFV-1004's fix, in the same change or after it.
  • Cap only the collector. If the count handed to createResults were capped too, the division would give maxDoc / numFactor and truncate again (code reading).
  • Ceiling. A lookup returns at most Integer.MAX_VALUE / numFactor results, far above any realistic suggester. The fix removes the false errors; it does not make billions of results cheap (memory and time still bound them).
  • Equivalent to the finder's sketch, which saturates at the largest multiple of numFactor that fits an int: both give createResults the same Integer.MAX_VALUE / numFactor.
  • numFactor <= 0, not addressed. The constructors do not check numFactor (a remark of the intended-lens reviewer). The fix changes a result only where the true product exceeds Integer.MAX_VALUE; with a negative numFactor that takes a negative num (-2,147,483,647 x -2: -2 today, Integer.MAX_VALUE with the fix), and a negative product can still wrap (Integer.MAX_VALUE x -10 gives 10 before and after). A patch could validate numFactor > 0 in the constructors; not part of this report.

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.
  • With the fix and the repro test, the whole lucene/suggest module passes with assertions on (seed DEADBEEF) and off (seed 5EED0998): 32 suites, 237 tests, 7 skipped, 0 failures, TestBlendedInfixSuggester (9 tests) and TestAnalyzingInfixSuggester (23) among them.
  • :lucene:suggest:check -x test (checkGoogleJavaFormat, ecjLint, forbiddenApis) passes.
  • The finder's 4-case test (the silent 1-of-8 values for numFactor 10 and 1000, the NoSuchElementException value, a negative wrap) passes 4 of 4 with this fix.
  • Earlier, by the hunt: the finder's variant passes its test 4 of 4 and 144 upstream suggest tests with all the hunt unit's fixes (main a9779ef3c193); the diagram probe's fix runs return all 8 suggestions at every probed value.
  • Not run: nightly tests, a benchmark (one multiplication per lookup).

Fail loudly with Math.multiplyExact (not built)

Throw ArithmeticException instead of a misleading IllegalArgumentException or 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 the number of entries before multiplying (not built)

num = Math.min(num, getCount()) in BlendedInfixSuggester would also keep the parent's collector small.

Trade-offs

A second searcher acquire per lookup (getCount() acquires one, AnalyzingInfixSuggester.java:962-974); still needs the long product for very large suggesters; leaves the parent's own pre-size (LFV-1004) unfixed for AnalyzingInfixSuggester users.

Stop dividing the count back (not built)

Pass the caller's num to createResults separately from the fetch count, so nothing is recovered by num / numFactor.

Trade-offs

Removes the fragile round trip, but changes the protected createResults contract that subclasses may override: a bigger change than the bug needs.

Downstream note

Solr passes the request parameter suggest.count unchanged to lookup as num, so a Solr deployment with a BlendedInfixSuggester has to bound suggest.count itself.

How it was found

A differential probe ran every suggester with growing num and flagged this class: IllegalArgumentException at Integer.MAX_VALUE where num = 1000 returns all 8 matching entries. A 32-bit SMT bit-vector model then proved the wrap-free bound and found the silent short-list values, and each was executed on the stock jars.

The method in full143 words

Method: non-formal first signal, then a small formal model (the hunt's hunt unit for suggester num sizes, 2026-10-28, main a9779ef3c193).

  • First signal. A numeric-limits probe ran 9 Lookup configurations (8 classes) at growing num on the stock main jars (8 entries, 1 GB heap): BlendedInfixSuggester threw IllegalArgumentException at Integer.MAX_VALUE and 214,748,365 and NoSuchElementException at 429,496,730, where num = 1000 returns 8.
  • Model. A Z3 32-bit bit-vector model of the num arithmetic proved the wrap-free region num <= Integer.MAX_VALUE / numFactor, showed the bound tight, and produced the silent-truncation witnesses the probe had not shown (429,496,731 gives 1 of 8; 4,294,969 with numFactor 1000). An exhaustive evaluation of every positive int counted the outcomes, and a differential sweep covered 11 Lookup configurations.
  • Execution. Each witness ran on the stock jars, then became a JUnit; the minimal-repro step reduced it to one method.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-29, rechecked 2026-10-30): GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream trackers and the web. Closest: SOLR-7865 (2015) fixed the opposite symptom of this expression, too many results, and left today's single int product.

Searches6 searches

Related, not reports of this bug: SOLR-7865 (fixed 2015), the same expression with the opposite symptom; its fix created the single int product, and nobody there mentions overflow or large counts. #9390 (LUCENE-8343, fixed 2018), a different overflow (the long score) in the same class. #14152 (open), only whether DEFAULT_NUM_FACTOR should be final. #7738 (LUCENE-6680), the TreeSet comparator in createResults; no num arithmetic. Two Solr user threads (2019, 2021) about fewer results at small counts, from duplicates. Elasticsearch and other reports of "numHits must be > 0" come from a search size of 0, with no suggester. Open PR #16847 touches the file but not lines 199 or 227. Tracking issue #16937 does not mention this bug, and none of its three comments (re-checked 2026-10-30) is about it: no maintainer has commented on this bug there. Gaps: GitHub search returns at most 50 items per query; inline PR review comments are not searchable.

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-29four scripted prior-report search runs of 51 queries each and 22 manual GitHub searches over six repositories and all of GitHubno report; only the related items below
Open and recent PRs2026-10-29the changed files of all 414 open apache/lucene PRs and the 93 issues and PRs updated since 2026-10-26only #16847, which keeps lines 199 and 227 unchanged
JIRA archive2026-10-299 JQL queries over LUCENE and SOLR, 11 issues read in fullno report
Mailing lists2026-10-2912 searches over dev@, java-user@, issues@ and commits@, 4 threads readno report
Web2026-10-2917 web searchesno report
GitHub (re-checks)2026-10-29 and 2026-10-30issues and PRs updated since the last check with their changed files, the newest commits on the file, the lines on upstream mainnothing new; the newest commit on the file is still ce50d3a135d (2025-02-24)

History

Introduced with the class by LUCENE-5354 (fb5fd910780, 2014-01-09; first release 4.7.0). SOLR-7865 (602a1f50e72, 2015) cut the call chain to today's single int product, which moved the threshold to 214,748,365. Executed on 21 releases from 4.7.0 to 10.5.2, 10.6.0 and main; unchanged on main.

Versions tested7 rows

Introduced: fb5fd910780 (trunk) / 89a9302d5a7 (branch_4x), 2014-01-09, LUCENE-5354 "add new BlendedInfixSuggester" (#6418; Remi Melisson's patch, committed by Michael McCandless). The class arrived with both halves: lookup overrides that pass num * numFactor down as an int, and int actualNum = num / numFactor (line 227 still blames to fb5fd910780).

Later changes: LUCENE-6050 (127eed36dd5, 5.0.0) and LUCENE-6464 (213cdf67930, 5.2.0) added overloads that multiplied again; SOLR-7865 (602a1f50e72, 2015; in 5.3.2, 5.4.1, 5.5.0 and later) kept only the last multiplication, still in int: line 199 today (blame 213cdf67930). So the default-factor threshold was 21,474,837 in 4.7.0-4.10.4 (num x 100), 2,147,484 in 5.0.0-5.3.1 and 5.4.0 (num x 1000) and 214,748,365 since. In 4.7.x a negative product threw NegativeArraySizeException instead. Old releases ran on JDK 25.

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 audit's tests fail
10.6.0 (4ae946e5a9b9), 10.5.2 affectedthis doc's repro fails on the release jars; the finder's test fails
5.3.2, 5.4.1, 5.5.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 affectedthe portable driver fails; threshold 214,748,365
5.0.0, 5.1.0, 5.2.0, 5.3.1, 5.4.0 affectedthe portable driver fails; threshold 2,147,484 (three multiplications)
4.7.0, 4.10.4 affectedthe portable driver fails; threshold 21,474,837 (two multiplications); 4.7.0 throws NegativeArraySizeException at Integer.MAX_VALUE
4.6.1 class absentno BlendedInfixSuggester
main + fix not affectedthe repro passes; 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. This doc re-ran a one-method repro on 645d4fe14a20 and validated the fix with gradle.

Checks and verdicts342 words

The cross-model reviews were reading-only: they could not run Java. The refute-lens review returned a procedural "not real, low" while agreeing on the mechanism and that the inputs are in contract; a separate re-run failed 4 of 4.

Each independent check and its verdict
CheckVerdictWhat it found
first signal (differential probe) flagged4 of 8 Lookup classes fail at huge num; BlendedInfixSuggester with IllegalArgumentException and NoSuchElementException, where num = 1000 returns 8.
finder candidateSMT model and exhaustive evaluation; 4-case JUnit fails 4 of 4 on main (-ea, -da), 10.5.2, 9.12.3 and the 10.6 release candidate; with only LFV-1004's fix it still fails 4 of 4.
cross-model review, refute lens not real, low (procedural)OpenAI GPT-5.6, reading-only; agreed on the root cause; raised the likelihood argument. A separate re-run failed 4 of 4.
Independent reproducer realOwn test, other package, own data: fails 4 of 5 on main, 10.5.2 and 9.12.3, the control passes; a 2 GB heap map of where the parent throws OutOfMemoryError.
Cross-model review, intended lens real, mediumOpenAI GPT-5.6, reading-only; rated the works-as-designed case moderate and rejected it. The step re-ran the repro: 4 of 4 fail.
Intended-behaviour devil's advocate real (Low)Rejected the small-num design, the parent's OutOfMemoryError, @lucene.experimental and misuse as correctness arguments; parent-vs-blended probe.
adjudication confirmed, highRe-ran the independent reproduction's test with a new seed (-ea, -da): 4 of 5 fail, the control passes.
Registration check and credibility audit keepFresh seeds on a clean checkout: 4 of 4 fail; the audit's own JUnit adds Integer.MAX_VALUE and a control: 5 of 6 fail, the control passes.
minimal-repro step verifiedThe one-method test fails on 645d4fe14a20 (-ea, -da, two seeds), passes with the fix, fails with only LFV-1004's cap.
brief re-runs repro and fix validatedGradle on a fresh scratch worktree: stock fails, fixed passes; the suggest module (237 tests) and check pass with the fix; the finder's 4 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,365 words

Finding record

LFV-998, snapshot of 2026-11-06

Registry title
BlendedInfixSuggester.lookup computes num * numFactor in int, so a large num wraps: num = Integer.MAX_VALUE throws IllegalArgumentException 'numHits must be > 0', some values throw NoSuchElementException, and specific values silently return fewer suggestions than exist
Area
suggest / analyzing: BlendedInfixSuggester lookup over-fetch (num * numFactor)
Priority
Low, rank 655: BlendedInfixSuggester's int num * numFactor wrap is the same class as the SpellChecker 10 * numSug row LFV-996 (mostly a misleading IllegalArgumentException at Integer.MAX_VALUE, silent truncation only at specific huge num values) in the opt-in suggest module.
Impact class
wrong error
Impact note
Applications that use BlendedInfixSuggester (any constructor, including the default numFactor 10) and pass a num above Integer.MAX_VALUE / numFactor (214,748,364 for the default factor, 2,147,483 for numFactor 1000, 3,579,139 for 600), most naturally Integer.MAX_VALUE as 'all suggestions', get a misleading IllegalArgumentException 'numHits must be > 0', or for some values a NoSuchElementException or a silently short list such as 1 of 8, on every such lookup at any index size; ordinary num values are not affected, and on stock code the same num range would otherwise fail with OutOfMemoryError in the parent class's collector pre-size (LFV-1004, a separate finding), so returning every match needs both fixes.
Summary
You build a BlendedInfixSuggester with its defaults (numFactor 10) from 8 entries that match 'luc' and call lookup("luc", false, Integer.MAX_VALUE) to get all suggestions; num is documented as the 'maximum number of results to return', so you expect the same 8 suggestions that num = 1000 returns. Instead the over-fetch count num * numFactor is computed in int (BlendedInfixSuggester.java:199) and wraps: a negative product throws IllegalArgumentException 'numHits must be > 0; please use TotalHitCountCollector ...' (num = Integer.MAX_VALUE or 214748365), a product below numFactor leaves room for 0 results and throws NoSuchElementException (num = 429496730), and a small positive product silently cuts the list (num = 429496731 returns only [lucas]; with numFactor 1000 already num = 4294969 does). So the number of suggestions can go down as num goes up.
Introduced
fb5fd910780d5862101cbd5f49f343b40eeca025 (trunk, svn r1556952) and 89a9302d5a743aca276621ba04196c1fc01d14a2 (branch_4x, r1556954): LUCENE-5354 'add new BlendedInfixSuggester' (#6418; Remi Melisson's patch, committed by Michael McCandless; Subversion era, no pull request). The class arrived with both halves of the defect: lookup overloads that pass num * numFactor down the chain as an int, and createResults' 'int actualNum = num / numFactor'. LUCENE-6050 (127eed36dd5 / 95ddd51108b, 2014-11-11, 5.0.0) and LUCENE-6464 (213cdf67930, 2015-05-15, 5.2.0) added overloads with their own multiplication; SOLR-7865 (602a1f50e72, author date 2015-12-18; in 5.3.2, 5.4.1, 5.5.0, 6.0.0) kept only the last multiplication, still in int, which is line 199 on main (blame 213cdf67930) [read: git log -S / blame in the full-history clone; exec: 4.6.1 lacks the class, 4.7.0 reproduces] (2014-01-09)
First affected version
4.7.0 [exec] (the class first shipped in 4.7.0; 4.6.1 has no BlendedInfixSuggester [exec])
Affected versions
Every release from 4.7.0 to 10.5.2, 10.6.0 (released 2026-10-29; run as its RC) and main [exec on 4.7.0 (-ea and -da), 4.10.4, 5.0.0, 5.1.0, 5.2.0, 5.3.1, 5.3.2, 5.4.0, 5.4.1, 5.5.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 RC 4ae946e5a9b9 and main a9779ef3c193 (-ea and -da) with the follow-up's portable public-API driver; the finder's JUnit fails on 9.12.3, 10.0.0, 10.5.2, the RC and main; the independent reproduction's on 9.12.3, 10.5.2 and main]; the int product and 'num / numFactor' are in all 110 local release tags from 4.7.0 on (8.9.0-8.11.3 are not tagged locally and are inferred) [read]. The wrap threshold changed with the number of multiplications on the path: num >= 21474837 in 4.7.0-4.10.4 (num * 100), num >= 2147484 in 5.0.0-5.3.1 and 5.4.0 (num * 1000), num >= 214748365 from 5.3.2, 5.4.1 and 5.5.0 on (num * 10, default factor) [exec: each release's outcomes match its multiplication count]. In 4.7.x a negative product threw NegativeArraySizeException instead of the IllegalArgumentException [exec on 4.7.0]. Not fixed: upstream main 645d4fe14a20 (2026-10-28) still has lines 199 and 227, and the newest commit on BlendedInfixSuggester.java is ce50d3a135d on main (author date 2025-02-24) and db775275305 on branch_10x and branch_10_6 (2025-02-12); no open PR changes these lines [read, recheck 2026-10-29]. Caveat: all runs used JDK 25.0.3 on Linux aarch64
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): four scripted prior-report searches of 51 queries each (GitHub issues and PRs, commits, downstream trackers, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, the web), 22 manual GitHub searches over apache/lucene, apache/solr, apache/lucene-solr, apache/lucenenet, OpenSearch, Elasticsearch and all of GitHub, all 93 apache/lucene issues and PRs updated since 2026-10-26, all 414 open apache/lucene PRs with their changed-file lists, 9 JQL queries over LUCENE and SOLR with 11 issues read in full, 12 mailing-list queries with 4 threads read, and 17 web searches; recheck 2026-10-29 (35 issues and PRs updated since 2026-10-28, their PR file lists, newest commits on the file, lines 199 and 227 on upstream main 645d4fe14a20, 3 searches): nothing new. Related, not this bug: SOLR-7865 (fixed 2015) is about the same expression with the opposite symptom (too many results, because the product was applied several times along the call chain); its fix left the single int product that wraps now. #9390 (LUCENE-8343, fixed 2018) fixed a different overflow (the long score weight * coefficient) in the same class. #14152 (open) asks only whether DEFAULT_NUM_FACTOR should be final. Open PR #16847 touches the file but not lines 199 or 227; #16857 touches TopFieldCollectorManager.java but not its numHits check. 'numHits must be > 0' reports elsewhere (Elasticsearch top_hits with size 0) involve no suggester. Gaps: GitHub search returns at most 50 items per query, and inline PR review comments are not searchable.
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28; runs of 2026-10-30) through gradle in a scratch worktree, with assertions on and off, and the 10.6.0 and 10.5.2 release jars through a standalone runner; the recommended fix was validated there (the repro, the whole lucene/suggest module with assertions on and off, :lucene:suggest:check -x test). The hunt and its checks ran on main a9779ef3c193 (the suggest module and TopFieldCollectorManager.java are identical), on 10.5.2, 9.12.3 and the 10.6 release candidate, and a portable public-API driver ran on 21 releases from 4.7.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/BlendedInfixSuggester.java:191-200 (last lookup overload, :198 comment 'We need to do num * numFactor here only because it is the last call in the lookup chain', :199 int product), :226-227 ('we reduce the num to the one initially requested' / 'int actualNum = num / numFactor'), :294-306 (boundedTreeAdd), :54 (@lucene.experimental); identical in Lucene main ea57360912ed
  • a Lucene main checkout lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:190 ('@param num maximum number of results to return'); lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingInfixSuggester.java:739 (TopFieldCollectorManager sized by num); lucene/core/src/java/org/apache/lucene/search/TopFieldCollectorManager.java:62-65 (numHits <= 0 -> IllegalArgumentException)
  • GitHub apache/lucene #7400 comment by mikemccand, 2015-03-26 ('could possibly overflow int? Maybe use long'), and PR #11923 (rmuir, 'enable error-prone "narrow calculation" check', merged 2022-11-15): both re-read read-only at the registration check; issue #16937 (the tracking issue, 0 comments)
The finding in 30 seconds
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:184-196, (at 645d4fe14a20) :206, :219
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/BlendedInfixSuggester.java:49-54, (at 645d4fe14a20) :64, :87-91, :97, :114, :152, :158-200, :213-228, :281, :294-306
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingInfixSuggester.java:516, (at 645d4fe14a20) :536, :617, :692-715, :739-745
  • lucene/core/src/java/org/apache/lucene/search/TopFieldCollectorManager.java:56-65 (at 645d4fe14a20)
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/BlendedInfixSuggester.java:54, (at 645d4fe14a20) :64, :90
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/AnalyzingInfixSuggester.java:617; lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/BlendedInfixSuggester.java:51-54, :226-227
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/TestBlendedInfixSuggester.java:228, (at 645d4fe14a20) :233, :356-389, :423, :465
Suggested fix
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingInfixSuggester.java:739-745, (at 645d4fe14a20) :962-974

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

Repro files

About the diagrams

Its values come from the code at 645d4fe14a20 and a probe executed on that commit.

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.