All findings · Lucene finding · LFV-960 · topgroups-merge-docoffset-plus-doctopn-overflow

TopGroups.merge throws NegativeArraySizeException when docOffset + docTopN overflows int

Merging grouped shard results with TopGroups.merge and docOffset 1, docTopN Integer.MAX_VALUE ("every document after the first in each group") throws NegativeArraySizeException: -2147483648, because merge adds the two numbers in int. docOffset 0 works; every release since 3.3 has it, and the demonstrated reach is hand-written shard-merge code (in-tree search paths fail earlier).

Summary

Priority
Low, rank 899TopGroups.merge overflows docOffset + docTopN and throws NegativeArraySizeException, which is the grouping-module, loud form of the core TopDocs.merge overflow LFV-262 and only hits hand-written shard-merge code.
Impact
crash
Since
3.3 (2011); LUCENE-3191 added TopGroups.mergeintroduced 2011-06-15 · over 15 years
Affected
3.3 to 10.6.0, and main
Status
documented
Found by
a z3 model of int sums, then a boundary probe

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

In sharded grouping each shard returns its top documents per group, and TopGroups.merge combines them: it merges the top docOffset + docTopN hits of each group, then drops the first docOffset.

Summary card: TopGroups.merge throws NegativeArraySizeException when docOffset + docTopN overflows int. Two shards' group g (one document each, scores 2 and 1) merged with docOffset 1 and docTopN Integer.MAX_VALUE: the int sum 1 + 2147483647 wraps to -2147483648, and TopDocs.mergeAux throws NegativeArraySizeException: -2147483648 while sizing its result array; docOffset 0, or docTopN Integer.MAX_VALUE - 1, works. Reach: hand-written shard-merge code (GroupingSearch and TopGroupsCollectorManager fail earlier). Fix: saturate the window.
Two shards each hold group g with one document (scores 2 and 1). Merging with docOffset 1 and docTopN Integer.MAX_VALUE adds 1 + 2147483647 in int, which wraps to -2147483648, and the merge throws instead of returning the second document. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Two shards each return group g with one document, scoring 2 and 1. You merge them with TopGroups.merge(shards, Sort.RELEVANCE, Sort.RELEVANCE, docOffset, docTopN, ScoreMergeMode.None).
  2. docOffset 0 with docTopN 10 or Integer.MAX_VALUE gives both documents; docOffset 1 with docTopN 10 gives the second one.
  3. You ask for every document after the first: docOffset 1, docTopN Integer.MAX_VALUE. You expect that same second document.
  4. You get NegativeArraySizeException: -2147483648, with assertions on or off. merge computes docOffset + docTopN in int: 1 + 2147483647 wraps to -2147483648, and TopDocs.merge sizes its result array with it.
  5. The edge is exactly the wrap: docOffset 1 with docTopN 2147483646 (sum 2^31 - 1) works; docOffset 2 with the same docTopN (sum 2^31) throws. A field sort inside groups fails the same way.

Who is affected

Code that calls TopGroups.merge itself, bounds each shard's per-group window on its own (for example at maxDoc), and passes docOffset 1 or more with a "no limit" docTopN: every such request fails, loudly. Lucene's GroupingSearch and TopGroupsCollectorManager fail earlier on these values (LFV-964); Solr passes a finite value when group.limit < 0.

Mechanism, terms and numbers419 words

TopGroups.merge combines the second-pass grouping results of several shards, then skips docOffset documents per group and keeps docTopN. It adds the two ints without an overflow check before calling TopDocs.merge, so docOffset 1 with docTopN Integer.MAX_VALUE ("all after the first") throws NegativeArraySizeException: -2147483648 as soon as one group's shard results hold a document, for relevance and field order. docOffset 0, or docTopN Integer.MAX_VALUE - 1, works. It has been there since 3.3 (2011). Lucene's own GroupingSearch and TopGroupsCollectorManager fail earlier on such values (LFV-964), so the demonstrated reachable path is hand-written shard-merge code that bounds each shard's window itself.

Terms

  • Two-pass grouping across shards: each shard runs the first pass, SearchGroup.merge picks the top groups, each shard runs the second pass for those groups, and TopGroups.merge combines the per-group documents. The grouping package leaves this to the caller: "Sharding is not directly supported, though is not too difficult, if you can merge the top groups and top documents per group yourself" (package-info.java:76-77).
  • docOffset, docTopN: "which document to start from within each group (for pagination)" and "how many top documents to keep within each group" (TopGroups.java:128-129). No bound is documented, and merge checks neither.

The bug

  1. For each group, merge calls TopDocs.merge(docOffset + docTopN, shardTopDocs) for relevance order (TopGroups.java:241) or TopDocs.merge(docSort, docOffset + docTopN, ...) for a field sort (:247). The sum is an int: 1 + 2147483647 is -2147483648.
  2. TopDocs.merge(topN, ...) is merge(0, topN, ...) (TopDocs.java:203-204, :216-217). When the group's shard results hold at least one ScoreDoc, mergeAux sizes its result with new ScoreDoc[Math.min(size, availHitCount - start)] (TopDocs.java:313), and the negative size throws NegativeArraySizeException. A group whose shards return no ScoreDoc takes the empty branch (:310-311), but one group with a returned ScoreDoc fails the whole call.
  3. The design itself handles "no limit": merge keeps the top hits from position 0, reads the group's best score from scoreDocs[0] (TopGroups.java:244-245) and drops the first docOffset itself (:250-260); core TopDocs.merge sizes by the available hits, not by topN. That is why docOffset 0 with Integer.MAX_VALUE works. Only the addition breaks.

The numbers

Executed on main 645d4fe14a20, two shards with one document each (scores 2 and 1), relevance and field order alike: (0, 10) and (0, 2147483647) give 2 documents; (1, 10) and (1, 2147483646) give 1; (1, 2147483647) and (2, 2147483646) throw -2147483648; (2, 2147483647) throws -2147483647.

Exposure and likelihood274 words
  • Who is hit (the demonstrated reachable path). Hand-written shard merging, the recipe the package docs point to (package-info.java:76-77), that bounds each shard's second-pass window itself and passes docOffset >= 1 with docTopN > Integer.MAX_VALUE - docOffset (typically Integer.MAX_VALUE) to the merge. An independent reproducer built exactly that (two indexes, the first pass, SearchGroup.merge, TopGroupsCollectorManager per shard with the window at maxDoc, then TopGroups.merge), and it fails on main and 10.5.2.
  • Who is not hit yet. TopGroupsCollectorManager.newCollector adds withinGroupOffset + maxDocsPerGroup itself (TopGroupsCollectorManager.java:107) before reduce calls merge (:116-122), so GroupingSearch and the manager fail first: IllegalArgumentException: numHits must be > 0 for (1, MAX), OutOfMemoryError for (0, MAX) with a field sort (that is LFV-964). If that sum is fixed and reduce still passes the caller's values on, this line becomes the first failure. Lucene's own sharded test passes the same window to each shard and to the merge (TestGrouping.java:1527, :1552-1557). Solr passes a finite docsPerGroup for group.limit < 0 since SOLR-4164; for group.limit >= 0 it passes the request's limit and offset to merge unchanged (read only; whether its shard requests fail earlier was not checked). A code search found no call in Elasticsearch or OpenSearch.
  • Default or opt-in. No setting needed, but the call is an expert one: a static helper of an @lucene.experimental class (TopGroups.java:38).
  • Effect. An exception instead of the merged groups whenever a group returns a document; no wrong or partial result. Workaround: docTopN = Integer.MAX_VALUE - docOffset, or a finite cap.
  • Scale. None: 2 documents. Likelihood: rare; no report since 3.3 (2011).

Minimal repro

The test shows that TopGroups.merge of two hand-built shard results with docOffset 1 and docTopN Integer.MAX_VALUE overflows the int sum docOffset + docTopN.

TestTopGroupsMergeOffsetOverflow.java (JUnit test)60 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.grouping;

import java.util.List;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.TotalHits;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestTopGroupsMergeOffsetOverflow extends LuceneTestCase {

  @SuppressWarnings("unchecked")
  private static TopGroups<BytesRef> shard(float score) {
    GroupDocs<BytesRef> group =
        new GroupDocs<>(
            Float.NaN,
            score,
            new TotalHits(1, TotalHits.Relation.EQUAL_TO),
            new ScoreDoc[] {new ScoreDoc(0, score)},
            new BytesRef("g"),
            new Object[] {score});
    return new TopGroups<>(
        Sort.RELEVANCE.getSort(), Sort.RELEVANCE.getSort(), 1, 1, new GroupDocs[] {group}, score);
  }

  public void testDocOffsetOneWithUnboundedDocTopN() {
    TopGroups<BytesRef> merged;
    try {
      merged =
          TopGroups.merge(
              List.of(shard(2f), shard(1f)),
              Sort.RELEVANCE,
              Sort.RELEVANCE,
              1,
              Integer.MAX_VALUE,
              TopGroups.ScoreMergeMode.None);
    } catch (NegativeArraySizeException e) {
      throw new AssertionError(
          "expected group g's second document, but merge(docOffset=1, docTopN=MAX_VALUE) threw " + e,
          e);
    }
    assertEquals(1, merged.groups[0].scoreDocs().length);
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-960/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/grouping/build/libs/lucene-grouping-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.search.grouping.TestTopGroupsMergeOffsetOverflow
Expected vs actual
Expected group g's second document (1 ScoreDoc); actual NegativeArraySizeException: -2147483648 from TopDocs.mergeAux via TopGroups.merge:241 (-ea and -da).

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

More files, environment, full output47 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected group g's second document, but merge(docOffset=1, docTopN=MAX_VALUE) threw java.lang.NegativeArraySizeException: -2147483648
       org.apache.lucene.search.grouping.TestTopGroupsMergeOffsetOverflow.testDocOffsetOneWithUnboundedDocTopN(TestTopGroupsMergeOffsetOverflow.java:54)
     Caused by: java.lang.NegativeArraySizeException: -2147483648
       org.apache.lucene.search.TopDocs.mergeAux(TopDocs.java:313)
       org.apache.lucene.search.TopDocs.merge(TopDocs.java:217)
       org.apache.lucene.search.TopDocs.merge(TopDocs.java:204)
       org.apache.lucene.search.grouping.TopGroups.merge(TopGroups.java:241)
       org.apache.lucene.search.grouping.TestTopGroupsMergeOffsetOverflow.testDocOffsetOneWithUnboundedDocTopN(TestTopGroupsMergeOffsetOverflow.java:46)

Why it looks unintended

  • No documented bound: docOffset is "which document to start from within each group", docTopN "how many top documents to keep".
  • It is an int wrap, not a limit: merge(1, 2147483646) works, merge(1, 2147483647) throws, merge(0, 2147483647) works.
  • The grouping module fixed the same offset + limit overflow pattern as a bug before, in its facet API (SOLR-3956, 2013).
The full argument155 words
  • Contract. TopGroups.java:128-129 give the two parameters' meaning and no bound; the documented preconditions are the same sorts and the same top groups on every shard (:113-115, :124-125). merge validates neither value.
  • The method does not need a bound. It does not allocate by docTopN; core TopDocs.merge accepts topN = Integer.MAX_VALUE and sizes by the available hits (TopDocs.java:313).
  • The exception is accidental: a JVM allocation failure inside core, not a defined rejection like the IllegalArgumentExceptions merge throws for mismatched shards (TopGroups.java:164-166, :202-210).
  • Precedent. SOLR-3956 (type Bug, fixed 2013, commit 1ad382f3046): for grouped facets the committer chose Integer.MAX_VALUE as "unlimited" and changed getFacetEntries(offset, limit) "to not have int overflow if Integer.MAX_VALUE < offset+limit" (Chris M. Hostetter, JIRA comment of 2013-03-27). Main still tests getFacetEntries(1, Integer.MAX_VALUE) (TestTermGroupFacetCollectorManager.java:106, :135).
  • Tests never tried it. Upstream tests merge with finite windows (TestTopGroups.java:174-181, TestGrouping.java:1552-1557).
The devil's-advocate check206 words

The strongest case (the refute review, the intended-behaviour check): an unwritten precondition that the merge window equals the per-shard second-pass window, which is itself docOffset + docTopN (TopGroupsCollectorManager.java:107, TestGrouping.java:1527), so any pair that overflows in merge has already failed in the shard; TopGroups is @lucene.experimental; sharding is "not directly supported". The refute reviewer rated it moderate and, on that reading, judged the inputs outside the contract ((1, MAX) "is not a supported pagination request"), while noting the javadoc states no bound, "so this is not a conclusive contractual refutation".

Why it is weakened, though not conclusively disproved: nothing documents that precondition; TestTopGroups feeds merge hand-built results, so it is a standalone utility; the merge is well defined whenever each shard returned all its candidates (a window capped at maxDoc); the boundary pair shows the limit is the wrap; and the same module treated the same pattern as a bug. The works-as-designed reviewer: "experimental status is not an input-domain restriction". Against it: the standard collection recipe always computes one positive int window before the merge, and the request is extreme, though it uses the conventional "no limit" sentinel that the zero-offset call accepts. Verdict: a defect, low severity, narrow reach.

Suggested fix

Recommended Saturate the window (validated)

Saturate the window before calling TopDocs.merge: int docWindow = (int) Math.min((long) docOffset + docTopN, Integer.MAX_VALUE); and pass docWindow at both call sites (+5/-2 in TopGroups.java). With it the test passes, and so do all 57 grouping tests and the module's checks.

// docOffset + docTopN can exceed Integer.MAX_VALUE (e.g. docTopN = Integer.MAX_VALUE for "all
// remaining documents"): saturate instead of wrapping to a negative size.
final int docWindow = (int) Math.min((long) docOffset + docTopN, Integer.MAX_VALUE);
...
mergedTopDocs = TopDocs.merge(docWindow, shardTopDocs);
...
mergedTopDocs = TopDocs.merge(docSort, docWindow, (TopFieldDocs[]) shardTopDocs);
All options and trade-offs3 options · 391 words

One fix was built and validated with gradle; two alternatives are compared below.

Saturate the window (validated) (recommended)

Compute docOffset + docTopN in long, cap it at Integer.MAX_VALUE, and pass that to both TopDocs.merge calls. The finder wrote this patch; the first verifier independently chose the same saturation semantics (inlined at both calls, without the local).

// docOffset + docTopN can exceed Integer.MAX_VALUE (e.g. docTopN = Integer.MAX_VALUE for "all
// remaining documents"): saturate instead of wrapping to a negative size.
final int docWindow = (int) Math.min((long) docOffset + docTopN, Integer.MAX_VALUE);
...
mergedTopDocs = TopDocs.merge(docWindow, shardTopDocs);
...
mergedTopDocs = TopDocs.merge(docSort, docWindow, (TopFieldDocs[]) shardTopDocs);

Trade-offs

Keeps the method's design (merge from position 0, take maxScore from scoreDocs[0], slice locally) and every non-wrapping result. Nothing is lost by saturating: a group's merged hits are one Java array. One long addition per call, before the per-group loop. It repairs the positive overflow only and is not input validation: for every pair whose sum is at most Integer.MAX_VALUE, negative values included, it passes exactly what main passes today, and merge still neither documents nor checks docOffset >= 0 and docTopN >= 0 (option C's checks would add that).

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle, JDK 25.0.3: stock, the repro fails (assertions on and off); with the fix it passes, with TestTopGroups; all of :lucene:grouping:test (15 suites, 57 tests) passes with assertions on and off; :lucene:grouping:check -x test passes.
  • Standalone runner with the class shadowed: the repro passes; on main a9779ef3c193 the finder's 2 tests, the first verifier's 5 tests and the audit's 3 tests passed with the same fix.

Call the paging overload TopDocs.merge(docOffset, docTopN, ...) (not built)

Let core skip the offset and drop the local slice (TopGroups.java:250-260).

Trade-offs

Core has the same addition (int requestedResultWindow = start + size, TopDocs.java:314, LFV-262: a page of null ScoreDocs), so it needs that fix first. It would also break maxScore for relevance order: TopGroups.java:244-245 reads the best score from scoreDocs[0], which would then be the first document after the offset.

Reject an overflowing pair with a clear exception (not recommended)

Validate the two values and use Math.addExact, or throw IllegalArgumentException.

Trade-offs

A clean message, but it still refuses the "all remaining" request that docOffset 0 and core TopDocs.merge accept.

Downstream note

An application that forwards an uncapped, user-chosen per-group offset and page size into its own TopGroups.merge call fails that request with this exception; capping docTopN avoids it.

How it was found

A z3 model of the grouping module's int offset + limit sums flagged this call: it is satisfiable at docOffset 1, docTopN Integer.MAX_VALUE. A boundary grid over the documented sharding recipe on main then threw exactly where the sum reaches 2^31, and it was reduced to one public-API test.

The method in full135 words

Method: semi-formal. A hunt unit on the grouping module's int offset + limit sums (GroupingSearch, FirstPassGroupingCollectorManager, TopGroups.merge) wrote a z3 model of each addition site. Query Q5 is satisfiable at docOffset 1, docTopN Integer.MAX_VALUE (sum -2^31); Q8 shows the wrap region is exactly docOffset + docTopN >= 2^31. The site had been noted, but never run, by an earlier unit that worked on core TopDocs.merge (LFV-262).

The unit then drove the documented sharding recipe on the stock main jars with a boundary grid of (docOffset, docTopN) up to Integer.MAX_VALUE, compared with a finite docTopN: (0, MAX) and (1, MAX-1) matched; (1, MAX), (2, MAX-1) and (2, MAX) threw, for relevance and field order. It reduced that to a public-API JUnit (2026-10-28), later minimised to the one-test repro.

Prior reports, history and checks

Prior reports

No report found: GitHub issues and PRs (including every open PR's file list), the JIRA archive, four mailing lists and the web, searched 2026-10-28 and rechecked 2026-10-31. Closest: #10342 (a different overflow in the same method) and SOLR-4164 (for group.limit < 0 Solr passes a finite value).

Searches4 searches

Nobody else has reported TopGroups.merge's docOffset + docTopN sum, a NegativeArraySizeException from it, or a bound on the two values. Related items concern other code or set precedents.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search, two runs2026-10-2851 queries each over apache/lucene issues and PRs, commits, downstream projects, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 2804 and 172 distinct hits, every high and medium hit readno report; related: #10342 / LUCENE-9302 (int total-hit counts in the same method), SOLR-4164, SOLR-3956 (a bug fix for the same offset + limit pattern in the grouped facet API), #5897 / LUCENE-4832 (a join-module enhancement, not a bug)
GitHub by hand2026-10-2827 searches (recorded as 25 entries, two of which hold two code-search queries each) over apache/lucene, lucene-solr, solr, lucenenet, Elasticsearch and OpenSearch (issues, commits, code); the changed-file lists of all 414 open apache/lucene PRs; the 93 items updated since 2026-10-26no report; only open PR #16548 touches TopGroups.java (totalHits relation only)
JIRA and mailing lists2026-10-2811 JQL queries (13 tickets read in full), 35 list queries, 7 web queriesno report
Recheck for this doc2026-10-31upstream main fetched; GitHub searches for TopGroups issues and PRs updated since 2026-10-27, NegativeArraySizeException issues, "TopGroups.merge"nothing new; main 2f424fd428d still has both sums

History

Born with the method: LUCENE-3191 (a1e74fd6c94 / backport c0713158f8c, 2011) added TopGroups.merge with the int sum; first release 3.3. Never fixed; later edits kept it. Executed failing on 18 releases from 3.3.0 to 10.5.2, the 10.6.0 release commit and main.

Versions tested7 rows

Introduced by LUCENE-3191, "Add TopDocs.merge to merge multiple TopDocs" (migrated issue #4264; author mikemccand; Subversion era, no PR). Its commits, both with the subject "add TopDocs.merge, TopGroups.merge, SearchGroup.merge, to make sharding easier": trunk commit a1e74fd6c94 (2011-06-15, first released in 4.0.0) and branch_3x backport c0713158f8c (2011-06-16, first released in 3.3). The parent has no TopGroups.merge. git log -S 'docOffset + docTopN' finds only that commit and e4436aa7fec (LUCENE-6220, 2015), which split the call into the two now at TopGroups.java:241 and :247. Later edits, among them LUCENE-5515 (paging TopDocs.merge, 4.8.0), LUCENE-6900, #15879, #15897, #16305 and #16516 (not a complete list), kept it.

Not a regression. All 127 release tags from 3.3 to 10.5.2 have the sum (read); the 10.6.0 tag has it at :239 and :245. 3.2.0 has no TopGroups.merge.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one-test repro fails through gradle and the standalone runner, assertions on and off; the boundary probe throws for every sum >= 2^31
main a9779ef3c193 affected4 wrapping cases throw, 4 controls pass (assertions on and off); the finder's, the first verifier's and the audit's JUnits fail
10.6.0 (release commit 4ae946e5a9b) affected4 of 4 throw (assertions on)
10.5.2, 9.12.3, 3.3.0 affected4 of 4 throw, assertions on and off
10.0.0, 9.0.0, 8.11.4, 8.0.0, 7.7.3, 7.0.0, 6.6.6, 6.0.0, 5.5.0, 5.4.1, 5.0.0, 4.10.4, 4.0.0, 3.6.2, 3.4.0 affected4 of 4 throw (assertions on)
3.2.0 no APITopGroups.merge does not exist
fix (saturated window) on 645d4fe14a20 not affectedthe repro and all 57 grouping tests pass

Independent checks

A finder, an independent reproducer, reviews by another model family (reading only), an intended-behaviour devil's advocate, an adjudication and a credibility audit agree on the root cause; every executed check fails on main with assertions on and off and passes with the saturating fix.

Checks and verdicts321 words

The cross-model reviews ran reading-only (they could not run code); every claim they read was executed by the other checks. The hunt's checks ran on main a9779ef3c193, whose relevant lines equal 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatez3 site model, boundary grid, 2-test JUnit: fails 2 of 2 on main (-ea, -da), the docOffset 0 control passes; passes with the fix, as do 29 upstream grouping tests.
cross-model review, refute lens not real (low), proceduralOpenAI GPT-5.6, reading only: could not run Java, so its "not real" is procedural; root cause agrees; intended argument moderate, and on the implicit-window reading it judged the inputs outside the contract, while calling that "not a conclusive contractual refutation"; raised the reach caveat (in-tree callers fail earlier).
Independent reproducer realOwn 5-test JUnit (hand-built and end-to-end sharded, plus a control): 4 of 5 fail on main and 10.5.2, 2 of 3 on 9.12.3; own saturating fix passes 5 of 5.
Cross-model review, works-as-designed lens real, mediumOpenAI GPT-5.6, reading only: intended argument weak, inputs in contract; a separate run showed the standard per-shard paths fail before any merge.
Intended-behaviour devil's advocate real, medium-highBoundary probe, SOLR-3956 precedent, finder's JUnit re-run (fails; passes with the fix).
adjudication confirmed, highRe-ran the finder's and the independent reproduction's JUnits with new seeds (fail) and the independent reproduction's with the fix (pass).
credibility audit keepFinder's JUnit fails 2 of 2; an independent hand-built JUnit fails the two wrapping pairs with the (1, MAX-1) control passing, and passes with the fix.
this doc confirmed on 645d4fe14a20The one-test repro fails through gradle and the standalone runner (assertions on and off); a 14-pair probe pins the edge for both sort kinds (four named outcomes asserted per sort, the rest observed in its output); the fix is validated with gradle.

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

Finding record

LFV-960, snapshot of 2026-11-06

Registry title
TopGroups.merge throws NegativeArraySizeException when docOffset + docTopN overflows int, for example docOffset = 1 with docTopN = Integer.MAX_VALUE ('all remaining documents'), although docOffset 0 with the same docTopN works
Area
grouping / TopGroups.merge (merging second-pass grouped results across shards or slices)
Priority
Low, rank 899: TopGroups.merge overflows docOffset + docTopN and throws NegativeArraySizeException, which is the grouping-module, loud form of the core TopDocs.merge overflow LFV-262 and only hits hand-written shard-merge code.
Impact class
crash
Impact note
Only code that merges grouped shard results itself with the public, @lucene.experimental static TopGroups.merge (the package docs leave sharding to the caller), bounds each shard's second-pass window on its own (for example at maxDoc) and passes docOffset >= 1 with docTopN > Integer.MAX_VALUE - docOffset, typically Integer.MAX_VALUE as 'all remaining documents', is hit; merge then throws NegativeArraySizeException every time, for relevance and field order within groups, with no silent wrong result; Lucene's own GroupingSearch and TopGroupsCollectorManager fail earlier, in the per-shard collector, for the same values; no setting or index size is needed, and every release from 3.3 to 10.6.0 and main is affected.
Summary
You merge two shards' second-pass grouping results with TopGroups.merge(shardTopGroups, groupSort, docSort, 1, Integer.MAX_VALUE, ...) to get every document after the first in each group, and expect the same groups as with docTopN = 10 on the same small data, just as docOffset 0 with docTopN = Integer.MAX_VALUE already gives. Instead merge throws NegativeArraySizeException: -2147483648: it passes the int sum docOffset + docTopN to TopDocs.merge, the sum wraps negative, and TopDocs.mergeAux sizes its result array with it. Every pair with docOffset + docTopN >= 2^31 fails, for relevance and field order within groups; a sum of 2^31 - 1 still works.
Introduced
a1e74fd6c9463558e3c052f61dfd53c00328902f (trunk, svn r1136080, first released in 4.0.0) and its branch_3x backport c0713158f8c466146a09d2a4d974c656b379c209 (svn r1136465, first released in 3.3): LUCENE-3191 'Add TopDocs.merge to merge multiple TopDocs' (migrated issue #4264; svn era, no GitHub PR) added TopGroups.merge with the int sum docOffset + docTopN [read: git log -S 'docOffset + docTopN' finds exactly a1e74fd6c94, which adds it, and e4436aa7fec (LUCENE-6220, 2015), which splits it into the two calls now at TopGroups.java:241 and :247; it was never removed] (2011-06-15 (author date of a1e74fd6c94); 2011-06-16 (author date of the branch_3x backport c0713158f8c))
First affected version
3.3 [exec: 3.3.0 throws NegativeArraySizeException: -2147483648 for docOffset 1 + docTopN Integer.MAX_VALUE with -ea and -da]; 3.2.0 has no TopGroups.merge [exec: API-presence check, the probe does not compile]
Affected versions
Every release from 3.3 to 10.5.2 [read: the int sum is in all 127 release tags from 3.3 to 10.5.2 in the history clone; exec on 18 releases: 3.3.0, 3.4.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 5.4.1, 5.5.0, 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 and 10.5.2 with -ea, and with -da also on 3.3.0, 9.12.3 and 10.5.2: all 4 overflow cases throw, all 4 non-wrapping controls pass], branch_10_6 4ae946e5a9b (10.6.0, released 2026-10-29) [exec, array-signature probe] and main a9779ef3c193 [exec, -ea and -da]; upstream main 645d4fe14a2 still has both sums and TopGroups.java was last changed by 897625db0f9 (2026-08-20, unrelated) [read]; not fixed
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
semi-formal: Z3
Upstream and prior reports
none found (the prior-report record, verdict new, searched 2026-10-28, read-only, plus the finder's scripted prior-report search): two scripted prior-report searches (51 queries each over apache/lucene issues and PRs, commits, downstream projects, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 2804 and 172 distinct hits, every high and medium hit read), 25 manual GitHub searches (apache/lucene, lucene-solr, solr, lucenenet, elasticsearch, OpenSearch; issues, commits and code), 11 JQL queries with 13 tickets read in full, 35 mailing-list queries, 7 web queries, the changed-file lists of all 414 open apache/lucene PRs and the 93 items updated since 2026-10-26; refresh 2026-10-28: no PR created since 2026-10-26 and no issue updated after a recent cutoff touches grouping, and no new commit on TopGroups.java; not fixed upstream (upstream main 645d4fe14a2 still has both sums at TopGroups.java:241 and :247); closest items, not reports: #10342 / LUCENE-9302 and lucene-solr PR #1411 (int total-hit-count accumulators in the same method, a different fix), SOLR-4164 (Solr caps the per-shard window and passes merge a finite docsPerGroup), SOLR-3956 and #5897 / LUCENE-4832 (precedents that offset + limit overflow and Integer.MAX_VALUE as 'all' are treated as bugs in grouping APIs), #15995, #15879, #15897, #16305 and #16516 (recent edits of the method, no size discussion), and #16548 (the only open PR touching TopGroups.java, totalHits relation only)
Lucene commit tested
main 645d4fe14a20 (2026-10-28) through gradle in a scratch worktree (removed afterwards), assertions on (seed DEADBEEF) and off (5EED0960): stock, the one-test repro fails; with the recommended fix it passes, together with TestTopGroups, all of :lucene:grouping:test (57 tests, both assertion modes) and :lucene:grouping:check -x test. Through a standalone runner on 645d4fe14a20: the repro (assertions on and off; fix shadowed: passes), a 14-pair boundary probe on the repro's data (relevance and field order, assertions on and off) and the audit's 2 + 3-document boundary JUnit. On main a9779ef3c193, where TopGroups.java, TopDocs.java and TopGroupsCollectorManager.java have the same lines: the finder's JUnit, an independent 5-test JUnit, a devil's-advocate probe, judge and audit re-runs. A release probe on 18 releases from 3.3.0 to 10.5.2 and on the 10.6.0 release commit 4ae946e5a9b. Upstream main 2f424fd428d (2026-10-30) has no change to the grouping module or TopDocs.java since 645d4fe14a20. OpenJDK 25.0.3, Linux aarch64 (the only platform run).

Lucene and upstream sources

Finding record
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroups.java:38 (at a9779ef3c193) (@lucene.experimental), :128-129 (docOffset and docTopN @param, no bound), :241 and :247 (TopDocs.merge(docOffset + docTopN, ...)), :250-260 (slice from docOffset); lucene/core/src/java/org/apache/lucene/search/TopDocs.java:313 (new ScoreDoc[Math.min(size, availHitCount - start)]); lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroupsCollectorManager.java:107 (newCollector sums withinGroupOffset + maxDocsPerGroup) and :116 (reduce calls TopGroups.merge); lucene/grouping/src/java/org/apache/lucene/search/grouping/package-info.java:73-78 (sharding left to the caller)
  • a Lucene clone tags: TopGroupsCollectorManager (#15603, backport 8e443458c2f) and #15995 (fbeed298542) are first in releases/lucene/10.5.0
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroups.java:112-141 (javadoc, signature), :183, :241, :244-245, :247, :250-260
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopDocs.java:203-204, :216-217, :310-313
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/package-info.java:73-78
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroupsCollectorManager.java:101-123; lucene/grouping/src/test/org/apache/lucene/search/grouping/TestGrouping.java:1527, :1552-1557; lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroups.java:38
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroups.java:113-115, :124-125, :128-129, :164-166, :202-210; lucene/core/src/java/org/apache/lucene/search/TopDocs.java:313
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/test/org/apache/lucene/search/grouping/TestTermGroupFacetCollectorManager.java:106-139; TestTopGroups.java:174-181; TestGrouping.java:1527, :1552-1557
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopDocs.java:314; lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroups.java:244-245, :250-260
History
  • a Lucene clone tag releases/lucene/10.6.0 (TopGroups.java:239, :245; commit 4ae946e5a9b)

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

Repro files

About the diagrams

It is made separately from this text; its numbers come from the executed repro on main.

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.