All findings · Lucene finding · LFV-958 · searchgroup-merge-presized-by-topn

SearchGroup.merge pre-sizes its result by topN, so merging shard groups with topN = Integer.MAX_VALUE throws OutOfMemoryError (and NullPointerException for offset >= 1)

Asking SearchGroup.merge for every group of your shards with topN = Integer.MAX_VALUE throws OutOfMemoryError, even for 2 groups, because since 6.2.0 it reserves a result slot per requested group. With offset = 1, offset + topN overflows and the call throws NullPointerException instead.

Summary

Priority
Low, rank 652SearchGroup.merge pre-sizes its list by topN and throws OutOfMemoryError at Integer.MAX_VALUE, which matches LFV-574's MoreLikeThis pre-size OOM that only direct callers hit.
Impact
crash
Since
6.2.0 for the OutOfMemoryError (LUCENE-7356, 2016); 3.3.0 for the offset >= 1 crash (2011)introduced 2016-06-28 · over 10 years
Affected
6.2.0 to 10.6.0 and main (offset 0); 3.3.0 to 10.6.0 and main (offset >= 1)
Status
documented
Found by
AI-assisted: a Z3 model of the grouping module's int size computations, then a probe on the jar

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

SearchGroup.merge reserves a result slot for every requested group before it knows how many groups the shards hold, and it adds offset to topN in an int.

Grouped search across shards in 3 steps

  1. Grouped search runs in two passes; the first pass finds each shard's (or slice's) top groups as SearchGroup objects, each with its group value and sort values.
  2. SearchGroup.merge(shardGroups, offset, topN, groupSort) combines them: it keeps a sorted candidate queue of at most offset + topN groups, skips the first offset and returns up to topN.
  3. Lucene's FirstPassGroupingCollectorManager calls it to combine the slices of one searcher; for shards, the grouping docs leave the merge to your code.
Summary card of SearchGroup.merge on two shard results a and b: with topN = 10 it returns [a, b]; with topN = Integer.MAX_VALUE, new ArrayList<>(topN) at SearchGroup.java:294 throws OutOfMemoryError (Requested array size exceeds VM limit); with offset 1, offset + topN wraps to -2,147,483,648 and line 274 throws NullPointerException; the fix returns the groups.
Two shard results, a and b, merged with topN = Integer.MAX_VALUE: the result list asks for 2,147,483,647 slots and the JVM refuses; with offset 1 the queue bound wraps negative. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You merge the top groups of two shards yourself, as the grouping docs suggest for sharding: shard 1 returned group a, shard 2 group b.
  2. To get every group you call SearchGroup.merge(shards, 0, Integer.MAX_VALUE, sort). You expect [a, b], what topN = 10 returns and what 6.1.0 returned for this call.
  3. You get OutOfMemoryError: Requested array size exceeds VM limit. The merger creates its result list as new ArrayList<>(topN): room for 2,147,483,647 groups to hold 2. Smaller "all" values stay under that limit but still reserve about 4 bytes per requested group: 400 MB for topN = 100,000,000, which throws OutOfMemoryError: Java heap space on a heap smaller than that (64 MiB, probe).
  4. With offset = 1 it fails sooner: the queue bound offset + topN wraps to -2,147,483,648, so the loop that trims the candidate queue empties it, takes one more and throws NullPointerException. That overflow dates from 3.3.0 (NoSuchElementException on 3.x, NullPointerException since 4.0.0).

Who is affected

Code that merges shard groups itself with SearchGroup.merge and passes Integer.MAX_VALUE (or MAX - 1) for "all groups", or any offset + topN >= 2^31 with a non-empty shard. Lucene's own FirstPassGroupingCollectorManager and GroupingSearch fail earlier for these values, in a separate bug. The failure is loud; large legal topN reserves about 4 bytes per requested group.

Mechanism, terms and numbers617 words

SearchGroup.merge(topGroups, offset, topN, groupSort) is the public, @lucene.experimental helper that merges the first-pass top groups of several shards. It creates its result list as new ArrayList<>(topN) (SearchGroup.java:294), sized by the request rather than by the groups the shards hold, so topN = Integer.MAX_VALUE (or MAX - 1), a common way to ask for every group, throws OutOfMemoryError: Requested array size exceeds VM limit at any offset, even when only 2 groups exist; smaller large values reserve about 4 bytes per requested group (400 MB at 10^8) and throw Java heap space when the heap is smaller than that. Up to 6.1.0 the offset-0 call returned the groups: the pre-size came with LUCENE-7356 in 6.2.0. Independently, the queue bound offset + topN is an int sum (:281), so with offset >= 1 and offset + topN >= 2^31 it wraps negative and, as soon as one shard holds a group, the pruning loop dereferences null (:274), as it has since the method was added in 3.3.0. The javadoc sets no bound and nothing checks the arguments.

Reach is narrow: Lucene's own FirstPassGroupingCollectorManager and GroupingSearch fail earlier for these values, in the first-pass collector (LFV-964), so only code that calls SearchGroup.merge directly is hit. The failure is loud: no wrong groups. A small fix in GroupMerger.merge (saturate the sum at :281 and size the list at :294 by the groups the shards hold, counted in a short loop) removes both failures and passes the grouping module's tests (57 with the repro).

The code (main 645d4fe14a20, SearchGroup.java)

  • :281: final int maxQueueSize = offset + topN; an int sum that wraps for offset + topN >= 2^31.
  • :271-275: after each insertion updateNextGroup prunes while (queue.size() > topN) (here topN is maxQueueSize) with queue.pollLast() and then group.inQueue = false, with no null check. With a negative bound it empties the TreeSet, pollLast() returns null and :274 throws NullPointerException: Cannot assign field "inQueue" because "group" is null.
  • :294: new ArrayList<>(topN): the result list is sized by the caller's topN. For Integer.MAX_VALUE and MAX - 1 the tested JVM (OpenJDK 25.0.3) refuses the array before allocating (Requested array size exceeds VM limit), with a 64 MiB heap as with the default heap; for smaller large values it allocates topN references, about 4 bytes each with compressed references.
  • :298-320: the pull loop stops when the queue is empty or at topN results, so it never needs more slots than there are groups.

The six calls on the repro's input (two shards: a with sort value 1, b with 2)

callstock mainwith the fix
merge(shards, 0, 10, sort)[a, b][a, b]
merge(shards, 0, Integer.MAX_VALUE, sort)OutOfMemoryError (VM limit) at :294[a, b]
merge(shards, 0, Integer.MAX_VALUE - 2, sort)OutOfMemoryError (Java heap space, -Xmx2g) at :294[a, b]
merge(shards, 1, 10, sort)[b][b]
merge(shards, 1, Integer.MAX_VALUE - 1, sort) (no wrap)OutOfMemoryError (VM limit) at :294[b]
merge(shards, 1, Integer.MAX_VALUE, sort) (wraps)NullPointerException at :274[b]

Assertions on and off give the same lines. So the pre-size fails at any offset, and only a wrapping sum fails earlier, while the queue is seeded.

Memory below the limit

One merge of three groups allocates 1,360 bytes for topN = 10, 4,001,320 for 10^6 and 400,001,320 for 10^8; with the fix 1,368 bytes for all three (measured on main a9779ef3c193, identical grouping code, OpenJDK 25.0.3 with compressed references). Below the VM limit the call needs that much free heap: topN = 10^8 throws OutOfMemoryError: Java heap space with -Xmx64m and returns the groups with -Xmx1g; 10^9 throws with -Xmx1g.

Exposure and likelihood310 words
  • Who is hit. Code that merges first-pass top groups itself with SearchGroup.merge and passes topN = Integer.MAX_VALUE (or MAX - 1) for "all remaining groups": every such call throws, at any offset, however few groups exist. With offset >= 1, any offset + topN >= 2^31 throws NullPointerException as soon as at least one shard holds a group (with only empty shards the pruning loop never runs, so there is no NullPointerException: the call returns no groups, or fails at the pre-size when topN itself is near Integer.MAX_VALUE; probe). Two executed examples: each shard collected by a bounded FirstPassGroupingCollectorManager(..., 0, 10) and then merged with merge(shardGroups, 0, Integer.MAX_VALUE, sort); and each shard collected completely (topNGroups = the shard's maxDoc) and merged with "all". Both throw; the same merge with a finite topN returns the groups.
  • Who is not. FirstPassGroupingCollectorManager and GroupingSearch callers with these values fail earlier, in the first-pass collector constructor: NegativeArraySizeException for (0, MAX), IllegalArgumentException "topNGroups must be >= 1 (got -2147483648)" for (1, MAX), OutOfMemoryError for (0, MAX - 1). That is LFV-964, with its own fix site. Callers with ordinary page sizes are unaffected.
  • Large but legal topN. Values such as 10^8 used as "all" reserve about 4 bytes per requested group per merge (400 MB at 10^8): they return the right groups when the heap has room, and throw OutOfMemoryError: Java heap space when it does not (10^8 with a 64 MiB heap, probe).
  • Default vs opt-in. No setting or index is involved; SearchGroup is @lucene.experimental; the trigger is the topN value of a direct call.
  • Severity and scale. An exception for that call; no wrong or missing groups, nothing persisted. Every release from 6.2.0 (offset 0) or 3.3.0 (offset >= 1) through 10.6.0, and main. Rare: the documented searches (Prior reports, below) found no public report.

Minimal repro

One JUnit test for lucene/grouping, public API only: two hand-built shard results with one group each, no index, single thread. It fails with assertions on and off, passes with the fix, and is also the suggested regression test.

TestSearchGroupMergeAllGroups.java (JUnit, public API)44 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.Collection;
import java.util.List;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSearchGroupMergeAllGroups extends LuceneTestCase {

  private static SearchGroup<String> group(String value, int sortValue) {
    SearchGroup<String> group = new SearchGroup<>();
    group.groupValue = value;
    group.sortValues = new Object[] {sortValue};
    return group;
  }

  public void testMergeWithTopNMaxValue() {
    List<Collection<SearchGroup<String>>> shards = List.of(List.of(group("a", 1)), List.of(group("b", 2)));
    Sort sort = new Sort(new SortField("p", SortField.Type.INT));
    try {
      assertEquals(2, SearchGroup.merge(shards, 0, Integer.MAX_VALUE, sort).size());
    } catch (OutOfMemoryError e) {
      // expected: both groups, as with topN = 10; actual: the result list is pre-sized by topN
      throw new AssertionError("expected the 2 merged groups for topN = Integer.MAX_VALUE, but got " + e, e);
    }
  }
}
Command
./gradlew :lucene:grouping:test --tests org.apache.lucene.search.grouping.TestSearchGroupMergeAllGroups -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
merge(shards, 0, Integer.MAX_VALUE, sort) returns the 2 groups [a, b], as with topN = 10.
Observed
OutOfMemoryError: Requested array size exceeds VM limit from new ArrayList<>(topN) at SearchGroup.java:294, with assertions on and off.

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

More files, environment, full output309 words

Put the file in lucene/grouping/src/test/org/apache/lucene/search/grouping/ and run it with gradle. The OutOfMemoryError is caught once and rethrown as an AssertionError whose message states expected against actual; the error stays as the cause. On the tested JVM (OpenJDK 25.0.3) the array is refused before anything is allocated, with a 64 MiB heap as with the default heap.

  • Formatting: google-java-format wraps two over-long lines (the shards declaration and the AssertionError); nothing else changes, and the wrapped file passes checkGoogleJavaFormat.
  • On unfixed code the test JVM also writes a heap dump of about 57 MB into lucene/grouping/build/tests-cwd/, because Lucene's build runs tests with -XX:+HeapDumpOnOutOfMemoryError.
  • The test covers the offset-0 leg. The offset >= 1 leg is the one-line call SearchGroup.merge(shards, 1, Integer.MAX_VALUE, sort) on the same input, which throws NullPointerException at SearchGroup.java:274 (probe); the same fix removes it.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 release jars (4ae946e5a9b9)
jdkOpenJDK 25.0.3
osLinux aarch64
Command (environment)
cp $REPRO/TestSearchGroupMergeAllGroups.java $LUCENE/lucene/grouping/src/test/org/apache/lucene/search/grouping/
cd $LUCENE
./gradlew :lucene:grouping:test --tests org.apache.lucene.search.grouping.TestSearchGroupMergeAllGroups -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)

Observed output

# the file above, main 645d4fe14a20, seed DEADBEEF, assertions on (JUnit console runner):
TestSearchGroupMergeAllGroups:testMergeWithTopNMaxValue
  => java.lang.AssertionError: expected the 2 merged groups for topN = Integer.MAX_VALUE, but got java.lang.OutOfMemoryError: Requested array size exceeds VM limit
     __randomizedtesting.SeedInfo.seed([DEADBEEF:E1AAF9DB920CFC30]:0)
     org.apache.lucene.search.grouping.TestSearchGroupMergeAllGroups.testMergeWithTopNMaxValue(TestSearchGroupMergeAllGroups.java:41)
   Caused by: java.lang.OutOfMemoryError: Requested array size exceeds VM limit
     java.base/java.util.ArrayList.<init>(ArrayList.java:157)
     org.apache.lucene.search.grouping.SearchGroup$GroupMerger.merge(SearchGroup.java:294)
     org.apache.lucene.search.grouping.SearchGroup.merge(SearchGroup.java:334)
     org.apache.lucene.search.grouping.TestSearchGroupMergeAllGroups.testMergeWithTopNMaxValue(TestSearchGroupMergeAllGroups.java:38)

# the same failure with assertions off, with gradle ("> Task :lucene:grouping:test FAILED", 1 test, 1 failure)
# and on the 10.6.0 release jars, there from SearchGroup.java:295

# the offset >= 1 leg, assertions off, from the hunt's two-method JUnit (two small indexes, first pass per shard, then the merge);
# the repro's own input gives the same exception at the same line (probe):
SearchGroup.merge(offset=1, topN=MAX_VALUE) threw java.lang.NullPointerException: Cannot assign field "inQueue" because "group" is null
  at org.apache.lucene.search.grouping.SearchGroup$GroupMerger.updateNextGroup(SearchGroup.java:274)
  at org.apache.lucene.search.grouping.SearchGroup$GroupMerger.merge(SearchGroup.java:289)
  at org.apache.lucene.search.grouping.SearchGroup.merge(SearchGroup.java:334)

Why it looks unintended

  • The javadoc sets no bound on offset or topN, and the method checks neither.
  • Up to 6.1.0 the same call returned the groups; the 6.2.0 change (LUCENE-7356) was a capacity tweak.
  • Grouped facet paging fixed the same offset + limit overflow (SOLR-3956) and still tests Integer.MAX_VALUE as "no limit"; core TopDocs.merge sizes by the hits available.
The full argument196 words
  • No documented bound. The javadoc (SearchGroup.java:326-331) only asks for a matching groupSort and fillFields=true; nothing checks offset or topN (:332-335). The package docs tell sharded users to do this merge themselves (package-info.java:76-77).
  • A regression from a micro-optimisation. LUCENE-7356 "SearchGroup tweaks (initialCapacity, size==0 vs. isEmpty)" (#8410, by Christine Poerschke, one "+1" from Adrien Grand) changed new ArrayList<>() to new ArrayList<>(topN). The issue text is just "initialCapacity" and "size()==0 vs. isEmpty()"; nobody discussed large topN. Every release from 3.3.0 (the first with the method) to 6.1.0 returns the groups for topN = Integer.MAX_VALUE (47 releases executed).
  • Same module, same pattern, fixed and tested. SOLR-3956 (1ad382f3046, Chris M. Hostetter, 2013) removed an offset + limit overflow from grouped-facet paging; its test still loops limit over {2, 10, 100, Integer.MAX_VALUE} (TestTermGroupFacetCollectorManager.java:106-139). Today GroupedFacetResult.getFacetEntries sizes by Math.min(limit, facetEntries.size() - offset) (GroupedFacetResult.java:91), and core TopDocs.merge by Math.min(size, availHitCount - start) (TopDocs.java:313).
  • An internal NPE is no answer. Whatever range is intended, a NullPointerException from polling an empty queue after a signed overflow names an internal field, not an argument. An IllegalArgumentException would be an acceptable alternative.
The devil's-advocate check135 words

The strongest case for "works as designed": topN is a concrete count on an @lucene.experimental expert helper; the grouping first pass deliberately allocates by topNGroups (FirstPassGroupingCollector.java:116, :121); Michael McCandless's LUCENE-2119 description calls Integer.MAX_VALUE "a nonsense value to pass in, since our PQ impl allocates the array up front"; and Lucene's own manager never reaches the merge with such values.

Why it fails: SearchGroup.merge has no pre-allocated priority queue; it prunes a TreeSet lazily and worked with Integer.MAX_VALUE for offset 0 from 3.3.0 to 6.1.0. LUCENE-2119 itself was resolved Fixed (IndexSearcher caps n at maxDoc) because "This trips users up fairly often". The module's own SOLR-3956 test treats Integer.MAX_VALUE as "no limit". The reach argument limits who is hit, not whether it is a bug, and the doc states that limit plainly.

Suggested fix

Recommended A: saturate offset + topN and size the result by the shard groups (validated)

Saturate the queue bound at Integer.MAX_VALUE and size the result list by the groups the shards hold (lines :281 and :294 plus a short count loop in GroupMerger.merge). The grouping module's tests, 57 with the repro, pass with assertions on and off. By reading, calls that work today return the same results: only the capacity and overflowing sums change.

// GroupMerger.merge, at the queue bound (was: final int maxQueueSize = offset + topN;)
final int maxQueueSize = (int) Math.min((long) offset + topN, Integer.MAX_VALUE);
long shardGroupCount = 0;
for (Collection<SearchGroup<T>> shard : shards) {
  shardGroupCount += shard.size();
}
// ... and at the result list (was: new ArrayList<>(topN))
final List<SearchGroup<T>> newTopGroups =
    new ArrayList<>((int) Math.min(topN, shardGroupCount));
All options and trade-offs3 options · 582 words

Three options; A was built and validated, B and C are alternatives from the hunt's checks.

A: saturate offset + topN and size the result by the shard groups (validated) (recommended)

Compute the queue bound in long and clamp it to Integer.MAX_VALUE, and create the result list with min(topN, total groups in the shard lists) slots. Both reproduced failures go away: the bound cannot wrap, and the list never reserves more slots than the shards hold groups, so LUCENE-7356's capacity hint stays useful for ordinary topN.

// GroupMerger.merge, at the queue bound (was: final int maxQueueSize = offset + topN;)
final int maxQueueSize = (int) Math.min((long) offset + topN, Integer.MAX_VALUE);
long shardGroupCount = 0;
for (Collection<SearchGroup<T>> shard : shards) {
  shardGroupCount += shard.size();
}
// ... and at the result list (was: new ArrayList<>(topN))
final List<SearchGroup<T>> newTopGroups =
    new ArrayList<>((int) Math.min(topN, shardGroupCount));

Trade-offs

One size() call per shard collection; the capacity is an upper bound (a group returned by several shards is counted once per shard), so it is only large when the shards really hold that many groups. Results unchanged is argued from the code and the module's tests, not tested exhaustively. Full diff:

diff --git a/lucene/grouping/src/java/org/apache/lucene/search/grouping/SearchGroup.java b/lucene/grouping/src/java/org/apache/lucene/search/grouping/SearchGroup.java
index 9f434b57d03..83e5167baf4 100644
--- a/lucene/grouping/src/java/org/apache/lucene/search/grouping/SearchGroup.java
+++ b/lucene/grouping/src/java/org/apache/lucene/search/grouping/SearchGroup.java
@@ -278,7 +278,12 @@ public class SearchGroup<T> {
     public Collection<SearchGroup<T>> merge(
         List<Collection<SearchGroup<T>>> shards, int offset, int topN) {
 
-      final int maxQueueSize = offset + topN;
+      // saturate: offset + topN may exceed Integer.MAX_VALUE (e.g. topN = Integer.MAX_VALUE)
+      final int maxQueueSize = (int) Math.min((long) offset + topN, Integer.MAX_VALUE);
+      long shardGroupCount = 0;
+      for (Collection<SearchGroup<T>> shard : shards) {
+        shardGroupCount += shard.size();
+      }
 
       // System.out.println("merge");
       // Init queue:
@@ -291,7 +296,9 @@ public class SearchGroup<T> {
       }
 
       // Pull merged topN groups:
-      final List<SearchGroup<T>> newTopGroups = new ArrayList<>(topN);
+      // size by the groups the shards hold, not by the caller's topN
+      final List<SearchGroup<T>> newTopGroups =
+          new ArrayList<>((int) Math.min(topN, shardGroupCount));
 
       int count = 0;
 

What the checks validated

  • gradle in a scratch worktree of main 645d4fe14a20: stock, the repro fails (OOM at :294); with the fix applied by git apply, ./gradlew :lucene:grouping:test -Ptests.seed=DEADBEEF passes with assertions on and with assertions off: 15 classes, 57 tests (the repro included), 0 failures, 0 skipped; :lucene:grouping:checkGoogleJavaFormat passes.
  • standalone runner with the fixed class ahead of the stock jars: the repro passes with assertions on and off, the hunt's two-method test (offset 0 and offset 1) passes 2 of 2, and all six probe calls return the groups.
  • the hunt's checks of the same code: 29 upstream grouping tests pass; one merge of three groups allocates a constant 1,368 bytes for topN 10, 10^6 and 10^8.

B: revert the pre-size and saturate the sum

Go back to new ArrayList<>() at :294 and saturate :281. The smallest diff.

Trade-offs

Gives up LUCENE-7356's capacity hint. Not built as one change; the version-history runs ran each half alone: reverting only :294 fixes offset 0 but keeps the NPE, saturating only :281 turns the NPE into the OOM, so both lines are needed.

C: reject out-of-range arguments with IllegalArgumentException

Validate offset >= 0, topN >= 1 and that offset + topN does not overflow.

Trade-offs

A clear error instead of an internal NPE, but it turns topN = Integer.MAX_VALUE, which worked for offset 0 up to 6.1.0 and which core TopDocs.merge accepts for hits, into an error. Not built; the adjudication and the audit call it an acceptable alternative.

Downstream note

Services that pass a client-chosen group page offset or size into SearchGroup.merge should bound both, since Integer.MAX_VALUE throws and large values reserve heap in proportion to the value.

How it was found

AI tools built a Z3 bit-vector model of every int size computation in the grouping module. It predicted that offset + topN wraps at SearchGroup.java:281 and that the pre-size at :294 exceeds the VM array limit; a shard-merge probe on the stock jar then threw both errors, while finite topN returned the groups.

The method in full130 words

Hunt unit "grouping offset + limit overflow" (2026-10-28), a semi-formal hunt:

  • The finder wrote a Z3 bit-vector model of every int size site in the grouping module. Site S6 is SearchGroup.java:281 (the sum) and :294 (the pre-size). Query Q6 is satisfiable at offset 1, topN Integer.MAX_VALUE (Java int sum -2^31); Q6b is satisfiable for the offset-0 pre-size above the VM array limit.
  • A shard-merge probe on the stock main jar, assertions off, witnessed both: OutOfMemoryError at :294 for topN MAX - 2 to MAX, and NullPointerException at :274 for offsets 1 and 2 with topN MAX, while finite topN returned the groups.
  • The unit was planned from a "top-n size parameters" area and an earlier, never-executed read-only lead on the same int-sum pattern (LFV-262, in core TopDocs.merge).

Prior reports, history and checks

Prior reports

No prior report in GitHub issues, PRs and commits (with the JIRA archive), JIRA, the mailing lists, downstream projects or the web (188 queries, 2026-10-28; re-checked 2026-10-30). Not fixed upstream: the file's last change, #16088 (2026-05-21), does not touch these lines.

Searches5 searches

Closest items, none the same bug: #8410 (LUCENE-7356) and #4264 (LUCENE-3191), the changes that introduced the two lines; #16088, #15896, #15888 and #15574 (recent SearchGroup and manager changes, none about large topN); #10342 (LUCENE-9302, open: int overflow of the total hit counts in TopGroups.merge, a different sum and site); SOLR-3956 (the same pattern, fixed in grouped facets in 2013); a 2012 solr-user thread (a ClassCastException in the same method, another cause). LFV-262, LFV-960 and LFV-964 are hunt rows at other fix sites.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, incl. the migrated JIRA archive)2026-10-28two scripted prior-report searches (code terms; use-case terms) plus the finder's run, 13 manual searches, the file history of SearchGroup.java, all 414 open PRs (file lists of the 7 grouping-labelled ones)no report
JIRA REST (LUCENE, SOLR)2026-10-2814 queriesSOLR-3956 (precedent, fixed); no report
Mailing lists (dev@, java-user@, solr-user@)2026-10-2810 searchesa 2012 solr-user thread with a different cause
Downstream projects and the web (Solr, Elasticsearch, OpenSearch, Lucene.NET, all GitHub)2026-10-2814 downstream and port searches, 10 code searches, 13 web searchesno report
GitHub re-check2026-10-306 issue/PR searches (SearchGroup merge, OutOfMemoryError, NullPointerException, topN, "Requested array size", grouping "Integer.MAX_VALUE"), file history, commits on lucene/grouping since 2026-10-28, file lists of the 32 open PRs updated since 2026-10-28nothing new; no commit or open PR touches lucene/grouping

History

The offset-0 OutOfMemoryError is a 6.2.0 regression from LUCENE-7356 (2016): 6.1.0 returns the groups. The offset >= 1 crash is as old as the method (LUCENE-3191, 3.3.0, 2011). A 136-release sweep confirms both boundaries; 10.6.0 and main fail both ways.

Versions tested7 rows
  • Pre-size leg. LUCENE-7356 "SearchGroup tweaks (initialCapacity, size==0 vs. isEmpty)" (#8410, by Christine Poerschke): e0c45f400f8 (master) and 0c43ae2b5c2 (branch_6x), both 2016-06-28, changed new ArrayList<>() to new ArrayList<>(topN). 0c43ae2b5c2 is not in 6.1.0; 6.2.0 is the first release with it. Swapping only SearchGroup.java between 6.1.0 and 6.2.0 flips the offset-0 result both ways.
  • Overflow leg. LUCENE-3191 (#4264, by Michael McCandless): a1e74fd6c94 (trunk, 2011-06-15) and c0713158f8c (branch_3x, 2011-06-16) added SearchGroup.merge with maxQueueSize = offset + topN; first release 3.3.0. LUCENE-3239 (b843dfe8396, Simon Willnauer, 2011-06-30) replaced queue.last() with pollLast(), which turned the 3.x NoSuchElementException into the NullPointerException from 4.0.0 on.
  • Sweep. 136 releases from 3.3.0 to 10.5.2, one run each with assertions off (6.1.0, 6.2.0, 9.12.3 and 10.5.2 also on): offset 0 returns the groups on 3.3.0-6.1.0 and on 5.5.3-5.5.5 (released after 6.2.0 from branch_5_5, without LUCENE-7356), and throws OutOfMemoryError on the 89 releases 6.2.0-10.5.2. Offset 1 throws NoSuchElementException on 3.3.0-3.6.2 and NullPointerException on 4.0.0-10.5.2. 8.9.0-8.11.4 are covered by execution only.
  • Not fixed upstream. The last commits on the file are #16088 (2026-05-21) and #15896 (2026-04-29); neither touches the two lines.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedOOM at :294 (assertions on and off, gradle and standalone); NPE at :274 for offset 1
10.6.0 (4ae946e5a9b9) affectedOOM at :295; NPE at :275 (release jars)
6.2.0 to 10.5.2 (89 releases) affectedOOM for offset 0; NPE for offset 1
4.0.0 to 6.1.0, 5.5.3 to 5.5.5 offset >= 1 onlyoffset 0 returns the groups; offset 1 throws NPE
3.3.0 to 3.6.2 offset >= 1 onlyoffset 0 returns the groups; offset 1 throws NoSuchElementException
3.2.0 not affectedno SearchGroup.merge method
6.1.0 vs 6.2.0, SearchGroup.java swapped bisectthe 6.2.0 file in 6.1.0 throws OOM; the 6.1.0 file in 6.2.0 returns the groups

Independent checks

An independent reproducer, an intended-behaviour devil's advocate, an adjudication and a credibility audit each re-ran it. Cross-model reviews (OpenAI GPT-5.6) could not run code: one said real, the other not real on procedure only; separate re-runs failed as claimed.

Checks and verdicts375 words

Separate AI tools checked the finder's claim, each with a lens; the adjudication re-ran the independent reproducer's test with new seeds, and this doc's runs re-checked it on main 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
Independent reproducer realOwn public-API JUnit in another package: 3 of 4 fail on main (assertions on and off), including offset 1 with topN MAX - 1 (no wrap). A bounded per-shard first pass then merge(0, MAX) gives the same OOM. 10.5.2, 9.12.3 and 6.2.0 fail; 6.1.0 fails only the offset-1 case.
Cross-model review, intended lens (OpenAI GPT-5.6, read-only) real, mediumAgreed on the root cause and that the inputs are in contract; a separate re-run failed 2 of 2 with assertions off.
Intended-behaviour devil's advocate real, low severityBuilt the "works as designed" case and rejected it; re-ran main, 6.1.0 (ok) and 6.2.0 (OOM); its reachability probe shows Lucene's own manager fails earlier.
Cross-model review, refute lens (OpenAI GPT-5.6, read-only) not real, low (procedural)Could run no code; agreed with the root cause and rated the intended argument weak; a separate re-run failed 2 of 2.
Adjudication confirmed, medium confidenceRe-ran the independent reproduction's JUnit with new seeds (1A387C01 assertions on, 1A387C02 off): 3 of 4 fail each, the oracle passes.
Credibility audit keepRe-ran the finder's JUnit (seed A0D1A4, assertions off): 2 of 2 fail; re-read the code and the 6.2.0 change.
This doc's runs (2026-10-30) reproduced; fix validatedRepro fails on main with assertions on and off and on the 10.6.0 release jars with assertions off; with the fix the grouping module's tests, 57 with the repro, pass with assertions on and off.
Fact checks of this doc (2026-10-30): an independent AI tool, and a cross-model reviewer (OpenAI GPT-5.6, read-only) behaviour real; wording fixedThe AI tool re-ran the gradle repro (assertions on and off), the fix, the probes and the release jars and found only wording errors, now fixed. The reviewer agreed the behaviour is real and the 6.2.0 attribution holds, but rated the "intended or misuse" case strong (a huge topN on an expert helper, a loud failure) and would treat it at most as a small low-priority hardening issue.

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

Finding record

LFV-958, snapshot of 2026-11-06

Registry title
SearchGroup.merge (the shard-merge helper of grouped search) with topN = Integer.MAX_VALUE ('all remaining groups') throws OutOfMemoryError because it pre-sizes its result list by topN (regression in 6.2.0, LUCENE-7356), and with offset >= 1 throws NullPointerException because offset + topN wraps to a negative queue bound (since 3.3.0)
Area
grouping / SearchGroup.merge (merging first-pass top groups across shards or slices; reduce step of FirstPassGroupingCollectorManager)
Priority
Low, rank 652: SearchGroup.merge pre-sizes its list by topN and throws OutOfMemoryError at Integer.MAX_VALUE, which matches LFV-574's MoreLikeThis pre-size OOM that only direct callers hit.
Impact class
crash
Impact note
Hits only code that calls the public, @lucene.experimental static SearchGroup.merge directly, for example a manual shard merge as the grouping package docs suggest for sharding, with topN = Integer.MAX_VALUE or MAX - 1 as 'all remaining groups' (OutOfMemoryError 'Requested array size exceeds VM limit' at any offset, even when only three groups exist) or with offset >= 1 and offset + topN >= 2^31 (NullPointerException); Lucene's own FirstPassGroupingCollectorManager.reduce, GroupingSearch and the usual per-shard (offset + topN) recipe fail earlier, in the first-pass collector constructor, for the same values (LFV-964); smaller large topN values only cost about 4 bytes per requested group (400 MB for topN = 10^8 on three groups); no setting or index size is needed, the failure is loud, returns no wrong groups and leaves no state behind; every release from 6.2.0 (OutOfMemoryError) or 3.3.0 (offset >= 1) to 10.6.0 and main is affected.
Summary
You merge the first-pass top groups of two shards (three groups in all) with SearchGroup.merge(shardGroups, 0, Integer.MAX_VALUE, groupSort) to get every group, and expect the three groups, the same as topN = 10 returns, as 6.1.0 and earlier returned for the same call, and as core TopDocs.merge(0, Integer.MAX_VALUE, ...) does for hits. Instead merge throws OutOfMemoryError 'Requested array size exceeds VM limit', because since 6.2.0 (LUCENE-7356) it pre-sizes its result list with new ArrayList<>(topN). With offset 1 it throws NullPointerException, because maxQueueSize = offset + topN wraps to a negative int and the pruning loop polls an empty queue (NoSuchElementException on 3.x).
Introduced
pre-size leg (OutOfMemoryError, the key's name): e0c45f400f83aab397b58ff2f83b2b11229576a7 (master) and its branch_6x commit 0c43ae2b5c2a13e16a14897335ea6facf7a3db43, LUCENE-7356 'SearchGroup tweaks (initialCapacity, size==0 vs. isEmpty)' (apache/lucene#8410; JIRA patch era, no pull request; author cpoerschke, one '+1' from jpountz, no discussion of large topN), which changed new ArrayList<>() to new ArrayList<>(topN) [read: git blame at the study base 7cecf795236, tag containment; exec: 6.1.0 passes and 6.2.0 fails, and swapping only SearchGroup.java between the two releases flips the result both ways; 0c43ae2b5c2 is the only commit on the file between them]. Overflow leg (offset >= 1): a1e74fd6c9463558e3c052f61dfd53c00328902f (trunk) and c0713158f8c466146a09d2a4d974c656b379c209 (branch_3x), LUCENE-3191 (apache/lucene#4264, mikemccand), the change that added SearchGroup.merge with 'final int maxQueueSize = offset + topN' [read: git blame; exec on 3.3.0 and later]; b843dfe8396 (LUCENE-3239, 2011-06-30) later changed queue.last() to pollLast(), which turned the 3.x NoSuchElementException into the NullPointerException from 4.0.0 on. (2016-06-28 (author date of e0c45f400f8 and 0c43ae2b5c2, pre-size leg); 2011-06-15 (a1e74fd6c946, trunk) and 2011-06-16 (c0713158f8c, branch_3x), overflow leg)
First affected version
6.2.0 for the OutOfMemoryError (offset 0, topN = Integer.MAX_VALUE) [exec: 6.1.0 passes, 6.2.0 fails, -ea and -da]; 3.3.0, the first release with SearchGroup.merge, for the offset >= 1 crash (NoSuchElementException on 3.3.0-3.6.2, NullPointerException from 4.0.0) [exec]; 3.2.0 has no merge method [exec: compile error, javap]
Affected versions
[exec] sweep of 136 releases from 3.3.0 to 10.5.2, one -da probe run each (public SearchGroup.merge on two hand-built shards, oracle topN = 10): offset 0 with topN MAX returns the groups on 3.3.0-6.1.0 and on 5.5.3-5.5.5 (released after 6.2.0 from branch_5_5, which never got LUCENE-7356) and throws OutOfMemoryError on the 89 releases 6.2.0-10.5.2; offset 1 with topN MAX throws NoSuchElementException on 3.3.0-3.6.2 and NullPointerException on the 130 releases 4.0.0-10.5.2; 6.1.0, 6.2.0, 9.12.3 and 10.5.2 also with -ea, same results. The candidate's JUnit fails 2 of 2 on 10.5.0 and 10.5.2 (-ea), on the branch_10_6 head 4ae946e5a9b (10.6.0 release candidate; -ea and -da) and on main a9779ef3c193 (-ea and -da; re-run at the registration check 2026-10-28: 2 of 2 fail with -ea and with -da, seed DEADBEEF); on 10.4.0 only its setUp fails to compile (FirstPassGroupingCollectorManager first shipped in 10.5.0), while the release probe there fails like every other 6.2.0+ release. [read] SearchGroup.java is byte-identical on main a9779ef3c193, the study base 7cecf795236 and upstream main (2026-10-28); branch_10_6 and branch_10x differ only by a cosmetic instanceof pattern; the last commits on the file are #16088 (author date 2026-05-21) and #15896 (2026-04-29), neither touching :281 or :294. Releases 8.9.0-8.11.4 are covered by execution only (not tagged in the history clone).
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, confidence medium-high; searched 2026-10-28, read-only; 188 queries in all): two scripted prior-report searches (A by code terms, B by use case; 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list and 4 JIRA-archive queries each) and the finder's 51-query run (0 high hits), 13 manual apache/lucene searches, 14 downstream and port searches, 10 code searches, 14 JIRA REST queries (LUCENE and SOLR), 10 mailing-list searches (solr-user@, users@solr, dev@solr, java-user@, dev@lucene), 13 web searches, 12 REST reads (file history of SearchGroup.java; all 414 open PRs listed and the file lists of the 7 grouping-labelled ones read; PRs and issues of the last 48 h) and local full-history git greps; refresh 2026-10-28: 5 GitHub searches, the changed files of all 50 open PRs updated since 2026-10-26 (none touches grouping or SearchGroup) and the latest commits on SearchGroup.java (#16088, author date 2026-05-21). Related, not reports: #8410 (LUCENE-7356, introduced the pre-size), #4264 (LUCENE-3191, introduced the int sum), #16088, #15896, #15888 and #15574 (none discusses large topN), #10342 (LUCENE-9302, open: int overflow of the total counts in TopGroups.merge, a different sum and site), SOLR-3956 (the same offset + limit overflow fixed in grouped facets, 2013), a 2012 solr-user thread (ClassCastException in the same method, different cause) and the hunt's LFV-262 (TopDocs.merge start + topN, a different fix site).
Lucene commit tested
main 645d4fe14a20 (2026-10-28; no commit has touched lucene/grouping upstream since, checked 2026-10-30): the minimal repro with the standalone runner (assertions on and off) and with gradle in a scratch worktree (stock: fails; with the fix: the whole grouping module, 57 tests, passes with assertions on and off, and the format check passes); a six-call probe of the repro's input on the stock jars and with the fix; the 10.6.0 release jars (4ae946e5a9b9). The hunt's checks ran on main a9779ef3c193 (identical grouping module), branch_10_6, 10.5.2, 9.12.3, 6.2.0 and 6.1.0, and a release-portable probe on 136 releases from 3.3.0 to 10.5.2. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/SearchGroup.java:38 (at a9779ef3c193) (@lucene.experimental), :270-275 (pruning loop 'while (queue.size() > topN)' with pollLast() and 'group.inQueue = false' at :274, no null check), :278-281 (GroupMerger.merge; 'final int maxQueueSize = offset + topN' in int at :281), :293-294 ('new ArrayList<>(topN)'), :298-322 (pull loop, stops at topN), :326-335 (static merge javadoc 'Merges multiple collections of top groups, for example obtained from separate index shards'; preconditions on groupSort and fillFields only; no bound on offset or topN, no validation)
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/FirstPassGroupingCollectorManager.java:105-112 (at a9779ef3c193) (newCollector with groupOffset + topNGroups), :114-126 (reduce calls SearchGroup.merge(allGroups, groupOffset, topNGroups, groupSort) at :125)
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/FirstPassGroupingCollector.java:116 (at a9779ef3c193) (getComparator(topNGroups + 1): where the in-tree path fails first for these values)
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/package-info.java:76-77 (at a9779ef3c193) ('Sharding is not directly supported, though is not too difficult, if you can merge the top groups and top documents per group yourself')
  • lucene/core/src/java/org/apache/lucene/search/TopDocs.java:291-315 (at a9779ef3c193) (the core merge sizes its result by Math.min(size, availHitCount - start))
  • lucene/grouping/src/test/org/apache/lucene/search/grouping/TestTermGroupFacetCollectorManager.java:106-118 (at a9779ef3c193) (SOLR-3956 test loops limit over {2, 10, 100, Integer.MAX_VALUE})
  • SECURITY.md lines 26-28, 36-38, 40-42, 44-46, 48-50 (as cited in the follow-up the security record)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/SearchGroup.java:38 (@lucene.experimental), :221, :270-275, :278-294, :298-322, :326-335
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/FirstPassGroupingCollectorManager.java:105-112, :114-126 (reduce calls SearchGroup.merge at :125)
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/package-info.java:76-77
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/FirstPassGroupingCollector.java:97-99, :116, :121
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/SearchGroup.java:38
Minimal repro
  • a Lucene main checkout (645d4fe14a20) build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/TestsAndRandomizationPlugin.java:451 (heap dump on OOM)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/SearchGroup.java:326-335
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/package-info.java:76-77
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupedFacetResult.java:91
  • a Lucene main checkout (645d4fe14a20) 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
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:9084 (Lucene 6.2.0, Optimizations: LUCENE-7356)

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

Repro files

About the diagrams

The PNG is the summary card.

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.