All findings · Lucene finding · LFV-1002 · topsuggestdocs-merge-presized-by-topn

TopSuggestDocs.merge sizes its queue by topN before it reads any shard, so merge(Integer.MAX_VALUE, shards) throws even for a handful of hits

TopSuggestDocs.merge(topN, shards) reserves a slot for each of the topN results before it reads the shards, so merge(Integer.MAX_VALUE, shards) ("keep them all") throws for 5 hits or none, while its documented analogue TopDocs.merge returns the hits. A topN of 100 million allocates about 400 MB.

Summary

Priority
Low, rank 653TopSuggestDocs.merge sizes its queue by topN, so it fails loudly on the Integer.MAX_VALUE 'all' idiom and never returns wrong results, the same shard-merge pre-size class as SearchGroup.merge LFV-958 but in an experimental suggest API that Lucene never calls.
Impact
crash
Since
5.1.0 (2015): the method was written this way (LUCENE-6339)introduced 2015-03-27 · over 11 years
Affected
every release from 5.1.0 to 10.6.0, and main
Status
documented
Found by
an executed probe comparing top-N APIs with their documented analogues

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

TopSuggestDocs.merge combines the suggestion lists of several shards into one top list. It reserves room for topN results before it counts what the shards hold, so a "no limit" topN fails even when there is nothing to merge.

Background: document suggestions and merge

  1. The document suggester (SuggestIndexSearcher over a SuggestField) returns completions as a TopSuggestDocs: a TopDocs whose hits also carry the completion text.
  2. An application with several indexes (shards) asks each for its top n suggestions and combines the lists with the static helper TopSuggestDocs.merge(topN, shards), the suggest counterpart of TopDocs.merge for ordinary hits.
  3. TopDocs.merge returns at most the hits it received, so topN = Integer.MAX_VALUE there simply means "all of them".
Summary of the bug. Two shards answer the prefix lu with suggest(query, 5, false): lucene 5, lucid 3, lumen 1 and luna 4, lunch 2. TopSuggestDocs.merge(Integer.MAX_VALUE, shards) is expected to return all 5, as merge(10, shards) and TopDocs.merge(Integer.MAX_VALUE, shards) do. Instead merge creates a PriorityQueue of topN slots before it reads any shard, and the queue throws IllegalArgumentException: maxSize must be >= 0 and < 2147483631; got: 2147483647. Below that limit, topN = 100,000,000 allocates about 400 MB for the 5 hits. The fix counts the hits first and sizes the queue by min(topN, hits).
Two shards, 5 suggestions, merge(Integer.MAX_VALUE, shards): the queue is sized by topN before any shard is read and refuses the size; TopDocs.merge sizes by the hits it received; the fix sizes by min(topN, hits). Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Two shards each answer a prefix query lu with suggest(query, 5, false): lucene (5), lucid (3), lumen (1) and luna (4), lunch (2). You combine them with TopSuggestDocs.merge(Integer.MAX_VALUE, shards) to keep every suggestion.
  2. You expect the 5 suggestions, best first. merge(10, shards) returns them, the javadoc puts no limit on topN and calls the method "Analogous to TopDocs#merge", and TopDocs.merge(Integer.MAX_VALUE, shards) returns all 5.
  3. You get IllegalArgumentException: maxSize must be >= 0 and < 2147483631; got: 2147483647, also for one empty shard or no shards at all (5.1.0 to 7.4.0: NegativeArraySizeException).
  4. Why: before it reads any shard, merge creates a priority queue of topN slots. The queue allocates maxSize + 1 slots up front and refuses 2,147,483,631 or more (the limit on the tested JVM). TopDocs.merge sizes by the hits it received instead.
  5. Below that limit the call can succeed, but memory follows topN, not the hits: merging the 5 hits with topN = 100,000,000 allocates 400,000,160 bytes; TopDocs.merge allocates 248.

Who is affected

Code that merges per-shard document-suggester results itself with TopSuggestDocs.merge (an experimental helper Lucene never calls) and passes a topN above the per-shard n, typically Integer.MAX_VALUE for "all". If the shards were collected with that same huge n, the collector throws first. The failure is loud, and this bug never returns wrong results.

Mechanism, terms and numbers577 words

TopSuggestDocs.merge(int topN, TopSuggestDocs[] shardHits) combines per-shard document-suggester results into one top list; its javadoc calls it "Analogous to TopDocs#merge(int, TopDocs[])" and sets no bound on topN. It creates its PriorityQueue with topN slots before it looks at any shard, and PriorityQueue pre-allocates maxSize + 1 slots and refuses maxSize >= 2147483631 (on the tested JVM). So merge(Integer.MAX_VALUE, shards) ("keep them all") throws IllegalArgumentException: maxSize must be >= 0 and < 2147483631; got: 2147483647 for two one-hit shards and even for a single empty shard (5.1.0 to 7.4.0: NegativeArraySizeException), while TopDocs.merge(Integer.MAX_VALUE, ...) returns the hits, because it sizes its queue by the number of shards and its result by the hits it received. Below the limit a large topN can succeed but allocates topN + 1 slots whatever the hit count (about 400 MB at topN = 10^8). The method has been like this since it was added in 5.1.0 (2015); Lucene itself never calls it; this bug never returns wrong results (merge either throws or over-allocates) and the failure is loud. If the shards were collected with the same huge n, the per-shard collector throws first, so only a merge topN above the per-shard n, or hand-built shard results, reach it.

The code (main 645d4fe14a20)

  • Contract. TopSuggestDocs.java:107-113: "Returns a new TopSuggestDocs, containing topN results across the provided TopSuggestDocs, sorting by score. Each TopSuggestDocs instance must be sorted. Analogous to TopDocs#merge(int, TopDocs[]) for TopSuggestDocs". No bound on topN, no validation. The class is @lucene.experimental (:30).
  • The pre-size. PriorityQueue.usingComparator(topN, SUGGEST_SCORE_DOC_COMPARATOR) (TopSuggestDocs.java:116-117), before the loop that inserts each shard's hits (:118-124).
  • The queue. PriorityQueue "pre-allocates an array of length maxSize+1" (PriorityQueue.java:34-35, @lucene.internal at :39) and, "to prevent confusing OOME", throws IllegalArgumentException for maxSize >= ArrayUtil.MAX_ARRAY_LENGTH (:121-124), which is Integer.MAX_VALUE minus the JVM's array header size (ArrayUtil.java:33-34): 2147483631 with compressed object pointers, as on the tested JVM, 2147483623 without; otherwise it allocates new Object[maxSize + 1] (:129, :134).
  • The analogue. TopDocs.merge(int topN, TopDocs[]) (TopDocs.java:203) sizes its queue by the number of shards (:283, :286), counts the hits it received (:303) and sizes its result Math.min(size, availHitCount - start) (:313): Michael McCandless's rule from commit 4e92af15376 (2011), "when merging TopDocs, the most hits we can produce is the sum of how many hits we actually received".
  • The per-shard side. SuggestIndexSearcher.suggest(query, n, ...) builds new TopSuggestDocsCollector(n, ...) (SuggestIndexSearcher.java:52), which also creates PriorityQueue.usingComparator(num, ...) (TopSuggestDocsCollector.java:81). A shard collected with n = Integer.MAX_VALUE therefore fails before merge runs.
  • No caller. Nothing in Lucene calls merge, tests included.

The numbers (main 645d4fe14a20, -ea and -da)

callresult
merge(10, shards), 5 real hits (3 + 2)5 hits
merge(Integer.MAX_VALUE, shards)IllegalArgumentException (5.1.0-7.4.0: NegativeArraySizeException)
merge(Integer.MAX_VALUE, {EMPTY}) and merge(Integer.MAX_VALUE, {})the same exception
merge(2147483630, shards)OutOfMemoryError: Java heap space at -Xmx2g
merge(100_000_000, shards)5 hits, 400,000,160 bytes allocated
TopDocs.merge(Integer.MAX_VALUE, shards) (control)5 hits; 248 bytes at topN = 10^8
new TopSuggestDocsCollector(Integer.MAX_VALUE, false)the same IllegalArgumentException

Bytes are allocated by the calling thread for one merge after a warm-up (not retained heap), with 4-byte object references (compressed object pointers); with 8-byte references the topN + 1 slots take about twice as much. With -Xmx256m, merge(100_000_000, 3 hits) ends in OutOfMemoryError at TopSuggestDocs.java:117; the out-of-memory part depends on the heap, the topN + 1 slots do not.

Exposure and likelihood264 words
  • Who is hit. Applications that keep document suggestions in several indexes and combine the per-shard TopSuggestDocs themselves, passing a merge topN larger than the n their shards were collected with (an independent check: real shards collected with n = 5, merged with Integer.MAX_VALUE, throw), or that merge hand-built TopSuggestDocs. "All" means every hit in the shards passed in: shards collected with suggest(query, 5, false) hold at most 5 each.
  • Reach caveat. In the plain reduce pattern (per-shard TopSuggestDocsCollector(n), then merge(n, ...) with the same n), Integer.MAX_VALUE already fails in the collector, so merge is never reached. The one public caller found, nrtsearch's MyTopSuggestDocsCollectorManager.reduce, uses that symmetric pattern.
  • Default vs opt-in. Calling merge needs no setting, but the API is @lucene.experimental, and real TopSuggestDocs come from a SuggestField, whose index needs a completion postings format in the codec.
  • What goes wrong. On the tested JVM every merge with topN >= 2147483631 throws, even with no hits, and merge(Integer.MAX_VALUE, ...) threw on every release tested since 5.1.0. The message names maxSize, a parameter of an internal class rather than merge's topN, and the internal array limit 2147483631; only its got: value is the caller's. A legal large topN costs topN + 1 reference slots per call, whatever the hit count.
  • Scale. None: two one-hit shards, or one empty shard, are enough.
  • Workaround. Cap topN at the total number of shard hits before calling merge; this works on stock code.
  • How likely. Rare: loud and immediate, no caller in Lucene, no earlier public report found.

Minimal repro

One JUnit test, public API: merge two hand-built one-hit shards with topN = Integer.MAX_VALUE. No index, one thread, fixed input; it fails with every seed tried, with assertions on and off. With the fix it passes unchanged, so it doubles as the regression test.

TestTopSuggestDocsMergeAllHits.java (lucene/suggest, package org.apache.lucene.search.suggest.document, 1 test)40 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search.suggest.document;

import org.apache.lucene.search.TotalHits;
import org.apache.lucene.search.suggest.document.TopSuggestDocs.SuggestScoreDoc;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestTopSuggestDocsMergeAllHits extends LuceneTestCase {

  private static TopSuggestDocs shard(String key, float score) {
    return new TopSuggestDocs(
        new TotalHits(1, TotalHits.Relation.EQUAL_TO),
        new SuggestScoreDoc[] {new SuggestScoreDoc(0, key, null, score)});
  }

  public void testMergeWithTopNMaxValue() {
    TopSuggestDocs[] shards = {shard("a", 1f), shard("b", 2f)};
    try {
      assertEquals(2, TopSuggestDocs.merge(Integer.MAX_VALUE, shards).scoreLookupDocs().length);
    } catch (IllegalArgumentException e) {
      // expected: both hits, as with topN = 10 and as TopDocs.merge returns; actual: the queue is pre-sized by topN
      throw new AssertionError("expected the 2 merged suggestions for topN = Integer.MAX_VALUE, but got " + e, e);
    }
  }
}
Command
cp $REPRO/TestTopSuggestDocsMergeAllHits.java $LUCENE/lucene/suggest/src/test/org/apache/lucene/search/suggest/document/
cd $LUCENE && ./gradlew :lucene:suggest:test --tests org.apache.lucene.search.suggest.document.TestTopSuggestDocsMergeAllHits -Ptests.seed=DEADBEEF
Expected
merge returns the 2 suggestions, b then a.
Observed
IllegalArgumentException: maxSize must be >= 0 and < 2147483631; got: 2147483647 from TopSuggestDocs.merge.

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

More files, environment, full output245 words

Copy the test into lucene/suggest/src/test/org/apache/lucene/search/suggest/document/ of a Lucene checkout and run the command below. It sits in that package only because the module's tests live there; it uses public constructors only. The catch only turns the exception into an assertion failure that states expected vs actual, with the original as the cause.

Seeds DEADBEEF, 5EED1234 and 1002FACE each fail on main with -ea and with -da, and C0FFEE42 with -da. Two lines are longer than 100 characters: before committing the test in a PR, run ./gradlew tidy, which rewraps the comment and the throw line and changes nothing else.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.5.2 and 10.6.0 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (the only platform it ran on)
Command (environment)
cp $REPRO/TestTopSuggestDocsMergeAllHits.java $LUCENE/lucene/suggest/src/test/org/apache/lucene/search/suggest/document/
cd $LUCENE && ./gradlew :lucene:suggest:test --tests org.apache.lucene.search.suggest.document.TestTopSuggestDocsMergeAllHits -Ptests.seed=DEADBEEF

Observed output

# gradle, fresh worktree at main 645d4fe14a20, -Ptests.seed=DEADBEEF (module prefixes trimmed)
TestTopSuggestDocsMergeAllHits > testMergeWithTopNMaxValue FAILED
    java.lang.AssertionError: expected the 2 merged suggestions for topN = Integer.MAX_VALUE, but got java.lang.IllegalArgumentException: maxSize must be >= 0 and < 2147483631; got: 2147483647
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:E1AAF9DB920CFC30]:0)
        at org.apache.lucene.search.suggest.document.TestTopSuggestDocsMergeAllHits.testMergeWithTopNMaxValue(TestTopSuggestDocsMergeAllHits.java:37)

        Caused by:
        java.lang.IllegalArgumentException: maxSize must be >= 0 and < 2147483631; got: 2147483647
            at org.apache.lucene.util.PriorityQueue.<init>(PriorityQueue.java:123)
            at org.apache.lucene.util.PriorityQueue.<init>(PriorityQueue.java:80)
            at org.apache.lucene.util.PriorityQueue.usingComparator(PriorityQueue.java:63)
            at org.apache.lucene.search.suggest.document.TopSuggestDocs.merge(TopSuggestDocs.java:117)
            at org.apache.lucene.search.suggest.document.TestTopSuggestDocsMergeAllHits.testMergeWithTopNMaxValue(TestTopSuggestDocsMergeAllHits.java:34)

# standalone runner (tests_run=1 failures=1 each)
main 645d4fe14a20  -ea and -da, seeds DEADBEEF, 5EED1234, 1002FACE   the same exception at TopSuggestDocs.java:117
10.6.0, 10.5.2     -da, seed DEADBEEF                                 the same exception, via SuggestScoreDocPriorityQueue.<init> at TopSuggestDocs.java:105

Why it looks unintended

  • The javadoc sets no bound on topN and calls merge "Analogous to TopDocs#merge", which returns every hit for the same call.
  • TopDocs.merge sizes by what it received on purpose (Michael McCandless, 2011): "the most hits we can produce is the sum of how many hits we actually received".
  • merge already holds every input, yet fails with nothing to merge.
The full argument200 words
  • Contract. TopSuggestDocs.java:107-113 promises "topN results across the provided TopSuggestDocs" and states only a sortedness precondition. The pre-allocation note sits on the @lucene.internal queue (PriorityQueue.java:34-35, :39); core documents pre-allocation where it applies, on its collector managers (TopScoreDocCollectorManager.java:45, :83), and merge has no such note.
  • Lucene's precedents put the cap in the code that sizes the queue. LUCENE-504 (#1581): Nadav Har'El, "It is the caller's duty to create a priority queue with a sensible size", with Mark Miller agreeing; Uwe Schindler fixed FuzzyQuery, the caller. LUCENE-2119: IndexSearcher caps n at maxDoc (IndexSearcher.java:721, :729). SOLR-5661: Michael McCandless, "somewhere it should limit the size it passes to the PQ constructor?".
  • The guard is not a public bound. It exists "to prevent confusing OOME" (PriorityQueue.java:122); it came with SOLR-5661 (2014) and was made to work for Integer.MAX_VALUE in 4c646dab3fb (2018, Mike McCandless, "thanks rsaavedraf", who diagnosed the overflow in lucene-solr PR #411).
  • History. No issue, test, commit message or review comment says the pre-size is intended. In the LUCENE-6339 review (#7400) Michael McCandless proposed a TopSuggestDocs.merge method; none of the 35 comments discusses its queue size. #14817 (2025) only swapped the queue class.
The devil's-advocate check270 words

The strongest case for "works as designed" (first raised by reviewers from another model family, OpenAI GPT-5.6, then developed by an intended-behaviour devil's advocate): "Analogous" may describe only the purpose, since merge also differs from TopDocs.merge in totalHits, tie-breaking and the missing start; the document suggester pre-sizes by n everywhere, and TopSuggestDocsCollector rejects Integer.MAX_VALUE the same way; Lucene's answer to Integer.MAX_VALUE queues reached through Solr's shard merge was exactly this clean exception (SOLR-5661), which Shalin Shekhar Mangar judged enough there; Cassandra Targett closed SOLR-5674, whose item 3 is this fix idea for Solr's code, as "a set of improvement requests"; and Adrien Grand's LUCENE-8542 comment that Lucene targets top-k "for some reasonable value of k" predicts the same answer. The queue's own refusal is also tested on purpose (TestPriorityQueue.testMaxIntSize, TestPriorityQueue.java:378-382), though that test says nothing about how merge sizes its queue. The cross-model fact check (OpenAI GPT-5.6, reading only) went further and judged the bug noise: an absurd capacity request that fails loudly, on an experimental API with no caller in Lucene and a trivial caller-side workaround.

Why it fails: those decisions concern Solr's code or core collectors, not this method; the guard (and its test) protects an internal class, and Lucene's own precedents fix the code that sizes the queue, here merge; the analogue it names already follows the "at most what you received" rule; and a collector must size before it sees matches, while merge already holds them all. The argument bears on reach and on the likely reply ("improvement, not a bug", "document the limit" or "remove merge"), hence priority Low.

Suggested fix

Recommended Count the hits first and size the queue by min(topN, hits)

Count the shard hits first and size the queue by min(topN, hits), the rule TopDocs.merge follows (Raintung Li proposed the same for Solr's merge in SOLR-5674). Results do not change; with it the test and the suggest module's 237 tests pass, and a merge of 3 hits at topN = 100,000,000 allocates 1,216 bytes instead of 400,001,208.

public static TopSuggestDocs merge(int topN, TopSuggestDocs[] shardHits) {
  long availHitCount = 0;
  for (TopSuggestDocs shardHit : shardHits) {
    availHitCount += shardHit.scoreLookupDocs().length;
  }
  // The result holds at most the hits received, as in TopDocs#merge, so do not size the queue
  // by topN alone: it may be far larger, e.g. Integer.MAX_VALUE for "all hits"
  int queueSize = (int) Math.min(topN, availHitCount);
  PriorityQueue<SuggestScoreDoc> priorityQueue =
      PriorityQueue.usingComparator(queueSize, SUGGEST_SCORE_DOC_COMPARATOR);
All options and trade-offs3 options · 653 words

Three options; A was validated with gradle in a fresh scratch worktree at 645d4fe14a20, B and C were not executed. Credit where the fix is presented: the sizing rule is Michael McCandless's (TopDocs.merge, 4e92af15376, 2011); Raintung Li first proposed it for Solr's merge (SOLR-5674 item 3, 2014: "Create the PriorityQueue after check all shard return sizes"); Nik Everett diagnosed and fixed the same failure, with the same message, in an Elasticsearch reduce step (elastic/elasticsearch#76492, reported by deathcave; fix #76578). Not part of this report: TopSuggestDocsCollector also pre-sizes by num (TopSuggestDocsCollector.java:81), but a collector must size before it sees matches, so it needs a different fix.

Count the hits first and size the queue by min(topN, hits) (validated by the checks) (recommended)

In TopSuggestDocs.merge (:116-117) sum scoreLookupDocs().length over the shards in a long and pass (int) Math.min(topN, availHitCount) to PriorityQueue.usingComparator. +8/-1 lines in one file; two of the added lines are a comment.

public static TopSuggestDocs merge(int topN, TopSuggestDocs[] shardHits) {
  long availHitCount = 0;
  for (TopSuggestDocs shardHit : shardHits) {
    availHitCount += shardHit.scoreLookupDocs().length;
  }
  // The result holds at most the hits received, as in TopDocs#merge, so do not size the queue
  // by topN alone: it may be far larger, e.g. Integer.MAX_VALUE for "all hits"
  int queueSize = (int) Math.min(topN, availHitCount);
  PriorityQueue<SuggestScoreDoc> priorityQueue =
      PriorityQueue.usingComparator(queueSize, SUGGEST_SCORE_DOC_COMPARATOR);

Trade-offs

  • No change in results. When topN is at most the hit count the queue size is topN, as before; when it is larger, a queue of exactly the hit count never rejects a hit, and neither did the larger queue.
  • For non-null input, topN = 0 and negative topN behave as before: 0 returns EMPTY, a negative value still throws from PriorityQueue. No hits gives a size-0 queue, which PriorityQueue accepts (PriorityQueue.java:117-119).
  • Null input still throws, but the exception can change: the counting loop reads every shard before the queue exists, so merge(-1, shards with a null entry), for example, throws NullPointerException where it threw IllegalArgumentException before.
  • Cost: one extra pass over the shard array, reading each array's length. The long sum cannot overflow.

What the checks validated

  • Fresh scratch worktree at 645d4fe14a20, JDK 25.0.3, gradle with -Ptests.seed=DEADBEEF (2026-10-30): without the fix the minimal test fails; with fix.diff applied, :lucene:suggest:test passes: 32 suites, 237 tests, 7 skipped, 0 failures, including the minimal test and the document package's TestSuggestField (20), TestContextQuery (15), TestPrefixCompletionQuery (9), TestContextSuggestField (6), TestRegexCompletionQuery (5) and TestFuzzyCompletionQuery (3).
  • :lucene:suggest:checkGoogleJavaFormat passes for the fix (it flagged only the copied test's two long lines; after tidy rewrapped them, the fix byte-identical, the check and the test pass).
  • The standalone runner passes the test with the fixed class shadowed, -ea and -da; an allocation test (3 hits, topN = 100,000,000, -da) drops from 400,001,208 to 1,216 bytes.
  • The finder's earlier version of the same idea passed its 3-test JUnit (including the empty-shard case) and the minimal test with -ea and -da.
  • An edge-case probe gives the same results with and without the fix for every legal topN (including 4 hits with a three-way score tie, at topN 1 to 100), EMPTY for topN 0 and for an empty merge, and IllegalArgumentException for topN -1. These cases stay out of the regression test, which is the one minimal test.

Grow the queue as hits arrive

Collect the hits and sort them, or use a growable heap. Not executed.

Trade-offs

Same results, more code, and no gain over A: merge already knows how many hits it holds.

Document a bound on topN, or reject a large topN

Add a javadoc note or a clear IllegalArgumentException for large topN. Not executed.

Trade-offs

Keeps the topN + 1 allocation for large legal values and makes the "keep them all" call a documented error, although TopDocs.merge handles it (its result holds at most the hits it received, TopDocs.java:313). Not recommended.

Downstream note

Elasticsearch's and OpenSearch's completion suggesters do not call merge; the one public caller found (nrtsearch) merges with the same n its collectors use, where the collector already fails first.

How it was found

A probe built from two earlier grouping findings of the same pattern called top-N APIs outside grouping with Integer.MAX_VALUE and compared each with its documented analogue: TopSuggestDocs.merge threw where TopDocs.merge returned the hits. A finder turned it into a JUnit with that control and found the queue line by reading the code.

The method in full136 words

Method: executed differential testing (no formal model), in a hunt round on numeric limits of public API arguments, on the stock main jars.

  • First signal. A planning probe generalised two earlier findings in the grouping module (a shard-merge helper and a grouping search, both pre-sized by an unbounded limit) to top-N APIs outside grouping. On main a9779ef3c193, 10.5.2 and the 10.6 release candidate (2026-10-28) it printed FAIL TopSuggestDocs.merge(MAX, {empty shard}) with the control TopDocs.merge(MAX, {empty shard}) -> 0.
  • Hunt. A finder re-ran it as a differential check against TopDocs.merge, wrote a 3-test JUnit with that control (2 of 3 fail), located the pre-size by reading the code and wrote the fix sketch.
  • Checks. Independent reproducers, an intended-behaviour devil's advocate and an adjudication confirmed it; the single test shown here is the audited reduction.

Prior reports, history and checks

Prior reports

No earlier report found (searched 2026-10-29, re-checked 2026-10-30). Related only: the same pattern handled elsewhere, in Solr's shard merge (SOLR-5661, SOLR-5674), IndexSearcher (LUCENE-2119) and an Elasticsearch reduce step.

Searches4 searches

Related, not this bug: SOLR-5661 and SOLR-5674 (Solr's shard merge with rows = Integer.MAX_VALUE), #1581 (LUCENE-504, a FuzzyQuery queue sized by Integer.MAX_VALUE, fixed in the caller), LUCENE-2119 (the IndexSearcher cap), #9588 (LUCENE-8542, open: core per-slice collectors pre-sized by numHits), elastic/elasticsearch#76492 (the identical message in an Elasticsearch reduce step, fixed there), #7400 (LUCENE-6339, which added merge; no comment on its queue size). The only open PR touching the file, draft #16847, changes doc-id types only. No maintainer has commented on this bug.

What was searched, when, and what turned up
WhereWhenHowFound
Search tool (GitHub issues/PRs/commits incl. the JIRA archive; dev@, java-user@, issues@, commits@; Elasticsearch, OpenSearch, Solr)2026-10-29two runs, each 16 GitHub issue/PR, 3 commit, 2 downstream, 20 list and 4 JIRA queries: class, method and symptom terms, then the queue pre-sizing patternno report of this bug
GitHub (apache/lucene, Elasticsearch, OpenSearch, Solr, nrtsearch, all of GitHub), JIRA, lists, web2026-10-29own searches for the exact message and for TopSuggestDocs.merge; a code search for callers; JIRA and list reads; 143 recorded queries in allrelated items only (above); one public caller, nrtsearch
Open PRs2026-10-29changed files of all 414 open apache/lucene PRs, then of the 48 PRs among the 61 issues and PRs updated in the 48 h before the recheckonly #16847 touches TopSuggestDocs.java, for doc-id types
GitHub, re-check2026-10-30TopSuggestDocs issues/PRs, "suggest merge topN", "maxSize must be >= 0", every issue and PR updated since 2026-10-29, the newest commit on the file, the #16847 patchnothing new; the file unchanged on upstream main 2f424fd428da; #16847 changes only int doc to long doc; re-run by the fact check with the same results

History

No regression: the method was written this way in 5.1.0 (2015, LUCENE-6339). Every release since fails, with NegativeArraySizeException up to 7.4.0 and IllegalArgumentException from 7.5.0, through 10.6.0 and main.

Versions tested6 rows

Introducing commits: b7adb6ac770 (trunk, 2015-03-27) and its branch_5x copy 40e94c39ad3 (2015-03-28, in 5.1.0), LUCENE-6339 "Near real time Document Suggester" (#7400, Areek Zillur), as new SuggestScoreDocPriorityQueue(topN). 8e2c6aa1d58 (#14817, Simon Cooper, 2025, main only) switched to PriorityQueue.usingComparator(topN, ...) and kept the size; 10.6.0 and branch_10_6 still have the old queue class (TopSuggestDocs.java:105 there), as did branch_10x until it was retired on 2026-10-29 (#16911). The exception type follows PriorityQueue's guard: before 7.5.0 it computed maxSize + 1 first, which wraps for Integer.MAX_VALUE, hence NegativeArraySizeException: -2147483648; 4c646dab3fb (2018, main; 7.5.0 has its branch_7x copy 154952f47d8) fixed that. The topN + 1 allocation is in every version.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle; standalone -ea and -da): IllegalArgumentException
10.6.0, 10.5.2 (release jars) affectedthe minimal test fails: IllegalArgumentException
7.5.0 to 10.5.2 8 releasesa release-faithful probe fails on 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2: IllegalArgumentException; the TopDocs.merge control returns 3 hits
5.1.0 to 7.4.0 4 releasesthe probe fails on 5.1.0, 5.5.5, 6.6.6 and 7.4.0: NegativeArraySizeException: -2147483648
5.0.0 not affectedTopSuggestDocs does not exist
fix A (scratch worktree at 645d4fe14a20) not affectedthe minimal test and the suggest module's 237 tests pass

Independent checks

An independent reproducer, an intended-behaviour devil's advocate, an adjudication, a history sweep, a credibility audit and a fresh-seed re-run confirm it; reading-only reviews by another model family agree on the root cause (one gave a procedural "not real": it could not run Java). This doc re-ran the test and validated the fix with gradle.

Checks and verdicts461 words

All cross-model reviews (two before this doc, a fact check of it) ran reading only and could not run Java; no executing cross-model review exists. The cross-model fact check judged the bug noise; the independent fact check disagreed (see "Why it is not intended").

Each independent check and its verdict
CheckVerdictWhat it found
finder (differential check) candidate3-test JUnit fails 2 of 3 on main (-ea, -da), the 10.6 RC, 10.5.2 and 9.12.3; the TopDocs.merge control passes; the fix passes 3 of 3.
cross-model review, refute lens (OpenAI GPT-5.6, reading only) procedural "not real" (low)Agreed on the root cause and that the inputs are in contract; raised "analogous may mean purpose only" (moderate). A separate run re-ran the repro with -da: 2 of 3 fail.
cross-model review, intended lens (OpenAI GPT-5.6, reading only) real (medium)"Exposing it here appears to be an undocumented implementation accident"; re-run with -da: 2 of 3 fail.
independent reproducer real, LowOwn 4-test JUnit fails 2 of 4 on main, 10.5.2 and 9.12.3; main() variant fails on 8.11.4 and 8.0.0; measured 400 MB for 3 hits at topN = 10^8.
intended-behaviour devil's advocate real (medium)Built the strongest intended case and rejected it; reach cases: the collector fails first with the same n, real shards collected with n = 5 and merged with Integer.MAX_VALUE fail, a caller cap works.
adjudication confirmed (medium)Re-ran the independent reproducer's test with a new seed, -ea and -da: 2 of 4 fail.
history sweep not a regressionRelease-faithful probe fails on 12 releases from 5.1.0 and on main.
credibility audit downgrade, keptFresh seed: 2 of 3 fail; impact lowered for the experimental API and the completion-format path.
minimal-repro audit verifiedThe one test fails on main (-ea, -da, two seeds) and passes with the fix.
this doc reproduced, fix validatedThe test fails on main (-ea, -da), 10.5.2 and 10.6.0; gradle in a fresh 645d4fe14a20 worktree fails without the fix and passes with fix A, with the suggest module's 237 tests and the format check.
fact check of this doc reproduced; two wording errors, correctedRe-ran the test on main (-ea, -da, three seeds), 10.5.2 and 10.6.0 and the doc's gradle command in a fresh clone, without and with fix A (237 tests pass); an edge-case probe backs every trade-off of fix A. The fix size (+8/-1) and the error-message sentence were corrected.
cross-model fact check (OpenAI GPT-5.6, reading only) facts sound; judged noiseFound the core failure real and the brief broadly sound; its wording corrections are applied. Judged the bug noise (absurd parameter, loud failure, defensible).

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

Finding record

LFV-1002, snapshot of 2026-11-06

Registry title
TopSuggestDocs.merge, documented as analogous to TopDocs.merge, sizes its priority queue by topN before it reads any shard, so merging with topN = Integer.MAX_VALUE ('all') throws IllegalArgumentException 'maxSize must be >= 0 and < 2147483631' even for 3 hits or no shards (NegativeArraySizeException before 7.5.0), while TopDocs.merge returns the hits; a large legal topN allocates topN + 1 queue slots for a few hits
Area
suggest / document suggester: TopSuggestDocs.merge (merging per-shard suggestion results)
Priority
Low, rank 653: TopSuggestDocs.merge sizes its queue by topN, so it fails loudly on the Integer.MAX_VALUE 'all' idiom and never returns wrong results, the same shard-merge pre-size class as SearchGroup.merge LFV-958 but in an experimental suggest API that Lucene never calls.
Impact class
crash
Impact note
Hits only applications that combine per-shard document-suggester results themselves with the public, @lucene.experimental static TopSuggestDocs.merge (Lucene itself never calls it) and pass a merge topN of 2147483631 or more, typically Integer.MAX_VALUE as 'all', that is larger than the n their shards were collected with (with the same huge n, TopSuggestDocsCollector throws the same exception first); every such merge then throws, even with no hits, in every release from 5.1.0 to 10.6.0 and on main, and a legal large topN allocates topN + 1 queue slots whatever the hit count (about 400 MB for 3 hits at topN = 10^8, an OutOfMemoryError on a 256 MB heap); no setting or index size is needed, the failure is loud, and the results are correct whenever the call succeeds.
Summary
You collect suggestions from two index shards (for example SuggestIndexSearcher.suggest(query, 5, false) on each, giving 3 and 2 hits) and combine them with TopSuggestDocs.merge(Integer.MAX_VALUE, shards) to keep them all; its javadoc calls it 'Analogous to TopDocs#merge(int, TopDocs[])' and sets no bound on topN, so you expect the 5 hits that merge(10, ...) returns and that TopDocs.merge(Integer.MAX_VALUE, ...) returns for ordinary hits. Instead merge throws IllegalArgumentException 'maxSize must be >= 0 and < 2147483631; got: 2147483647', even for a single empty shard, because it creates its PriorityQueue with topN slots before it looks at any shard (TopSuggestDocs.java:116-117), while TopDocs.merge sizes its queue by the number of shards and its result by the hits it received. Below that limit, a large topN such as 10^8 makes merge allocate topN + 1 slots for a handful of hits.
Introduced
b7adb6ac7704a7e6364ee0d831bf77cf849f2090 (trunk, svn r1669698) and its branch_5x copy 40e94c39ad3b0f018ae9647b7d30bd0cfe341d86 (svn r1669715, the commit in 5.1.0): LUCENE-6339 'Near real time Document Suggester' (apache/lucene#7400; Areek Zillur; Subversion-era patch, no pull request), which added merge with new SuggestScoreDocPriorityQueue(topN). Michael McCandless proposed a TopSuggestDocs.merge method in that review; none of the 35 comments discusses its queue size [read: git log and blame in the history clone, the #7400 thread; exec: 5.0.0 lacks the API, 5.1.0 fails]. 8e2c6aa1d58 (#14817, Simon Cooper, author date 2025-06-27, main only) replaced the queue class with PriorityQueue.usingComparator(topN, ...) and kept the size (git blame of main lines 116-117 points there). Not a regression. (2015-03-27 (b7adb6ac770, trunk); 2015-03-28 (40e94c39ad3, branch_5x))
First affected version
5.1.0 [exec] (tagged 2015-04-13; 5.0.0 has no TopSuggestDocs: the history probe does not compile there [exec])
Affected versions
Every release from 5.1.0 through 10.5.2, the branch_10_6 head 4ae946e5a9b (10.6.0, released 2026-10-29) and main a9779ef3c193. [exec] The follow-up's release-faithful main() probe fails on 5.1.0 (-ea and -da), 5.5.5, 6.6.6, 7.4.0, 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da): merge(Integer.MAX_VALUE) and merge(Integer.MAX_VALUE, {EMPTY}) throw, while the TopDocs.merge control returns 3 hits; the finder's JUnit fails 2 of 3 on main (-ea, -da), the 10.6 RC, 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 2 of 4 on main (-ea, -da), 10.5.2 and 9.12.3, and its main() variant fails on 8.11.4 and 8.0.0. Only the exception changed over time: NegativeArraySizeException -2147483648 in 5.1.0-7.4.0, IllegalArgumentException from 7.5.0 on (the PriorityQueue guard fix 4c646dab3fb, 2018, thanks rsaavedraf). The topN + 1 allocation is in every version (400,000,896 to 400,000,944 bytes for 3 hits at topN = 10^8, against about 1 KB for TopDocs.merge). [read] Not fixed: upstream main 645d4fe14a20 still sizes the queue by topN (line 117, checked 2026-10-29), and branch_10x and branch_10_6 still use new SuggestScoreDocPriorityQueue(topN) (#14817 was not backported; the file is unchanged there since 2021).
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal
Upstream and prior reports
none found (the prior-report record verdict new, confidence medium-high, searched 2026-10-29, read-only, 143 recorded queries: two scripted prior-report searches, each of 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list (dev@, java-user@, issues@, commits@) and 4 JIRA-archive queries; own gh searches over apache/lucene, all of GitHub for the exact message and for TopSuggestDocs.merge, Elasticsearch, OpenSearch, Solr and nrtsearch; a code search for callers; the changed files of all 414 open apache/lucene PRs; JIRA REST and list reads; web searches. Recheck 2026-10-29: all 61 apache/lucene issues and PRs updated in the last 48 h and the changed files of the 48 PRs among them (none touches TopSuggestDocs.java; #14673, a closed branch-merge PR, touches only other suggest files), the newest commit on TopSuggestDocs.java per branch (main dfd536186fe, author date 2026-04-01, cosmetic #15889; branch_10x and branch_10_6 2021), the queue line on upstream main 645d4fe14a20 (still topN) and 4 searches). Related, not reports of this bug: #7400 (LUCENE-6339, added merge; no comment on its queue size), SOLR-5661 and SOLR-5674 (Solr's shard merge with rows = Integer.MAX_VALUE; SOLR-5674 item 3 proposed creating the merge queue after checking the shard result sizes, for Solr's code, closed Won't Fix in 2018), #1581 (LUCENE-504: the caller should size the queue), lucene-solr PR #411 (the PriorityQueue Integer.MAX_VALUE guard), #9588 (LUCENE-8542, open: per-slice collectors pre-sized by numHits), elastic/elasticsearch#76492 (the same message in an Elasticsearch reduce step, fixed there in #76578), #14817 and the open draft #16847 (touch the file, not the sizing).
Lucene commit tested
main 645d4fe14a20 (11.0.0-SNAPSHOT): the minimal test with gradle in a fresh scratch worktree, 2026-10-30 (fails without the fix; with fix A it passes together with the suggest module's 237 tests and the format check), and the standalone runner with -ea and -da (seeds DEADBEEF, 5EED1234, 1002FACE); an allocation test without and with the fix; the 10.5.2 and 10.6.0 release jars (the minimal test fails). The hunt ran on main a9779ef3c193, where the suggest module, PriorityQueue.java and TopDocs.java are identical; the history sweep ran a release-faithful probe on 12 releases from 5.1.0 to 10.5.2 and on main. TopSuggestDocs.java is unchanged on upstream main 2f424fd428da (2026-10-30).

Lucene and upstream sources

Finding record
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/document/TopSuggestDocs.java:30 (at a9779ef3c193) (@lucene.experimental), :107-113 (merge javadoc: 'containing topN results ... Each TopSuggestDocs instance must be sorted. Analogous to TopDocs#merge(int, TopDocs[])'; no bound on topN), :115-117 (PriorityQueue.usingComparator(topN, SUGGEST_SCORE_DOC_COMPARATOR) before the shard loop), :128 (totalHits = number of merged results)
  • lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:34-35 (at a9779ef3c193) (NOTE: pre-allocates maxSize+1), :39 (@lucene.internal), :121-124 ('Throw exception to prevent confusing OOME', 'maxSize must be >= 0 and < ...'); lucene/core/src/java/org/apache/lucene/search/TopDocs.java:203 (merge(int topN, TopDocs[])), :278-315 (mergeAux: queue sized by shardHits.length at :283/:286, result new ScoreDoc[Math.min(size, availHitCount - start)] at :313); lucene/suggest/.../TopSuggestDocsCollector.java:76-81 (pre-sizes the same queue by num; only num > 0 checked)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/document/TopSuggestDocs.java:30, :107-113, :115-117, :118-124, :125-131
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:34-35, :39, :61-64, :117-134
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopDocs.java:196-205, :278-287, :291, :303, :313
  • a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/document/SuggestIndexSearcher.java:50-55; lucene/suggest/src/java/org/apache/lucene/search/suggest/document/TopSuggestDocsCollector.java:76-81
  • grep 'merge(' over lucene/suggest/src at 645d4fe14a20: declaration and javadoc only
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:33-34; lucene/core/src/java/org/apache/lucene/util/RamUsageEstimator.java:124-133 (array header 16 bytes with compressed object pointers, 24 without)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/document/TopSuggestDocs.java:30, :107-113, :127-128; lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:34-35, :39, :122
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollectorManager.java:45, :83; lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:721, :729; lucene/core/src/java/org/apache/lucene/search/TopDocs.java:296, :313
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/util/TestPriorityQueue.java:378-382 (testMaxIntSize)
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/document/TopSuggestDocs.java:116-124; lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:117-124; lucene/suggest/src/java/org/apache/lucene/search/suggest/document/TopSuggestDocsCollector.java:40-41, :81
History
  • a Lucene clone tag releases/lucene/10.6.0 (4ae946e5a9b9): TopSuggestDocs.java:104-105

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro and an executed probe on the stock main jars (two real shards, -ea and -da, with and without the finder's form of the fix). The fix lines drawn are the finder's form, which has the same logic as option A.

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.