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 runs in two passes; the first pass finds each shard's (or slice's) top groups as
SearchGroupobjects, each with its group value and sort values. SearchGroup.merge(shardGroups, offset, topN, groupSort)combines them: it keeps a sorted candidate queue of at mostoffset + topNgroups, skips the firstoffsetand returns up totopN.- Lucene's
FirstPassGroupingCollectorManagercalls it to combine the slices of one searcher; for shards, the grouping docs leave the merge to your code.
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.- You merge the top groups of two shards yourself, as the grouping docs suggest for sharding: shard 1 returned group
a, shard 2 groupb. - To get every group you call
SearchGroup.merge(shards, 0, Integer.MAX_VALUE, sort). You expect[a, b], whattopN = 10returns and what 6.1.0 returned for this call. - You get
OutOfMemoryError: Requested array size exceeds VM limit. The merger creates its result list asnew 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 throwsOutOfMemoryError: Java heap spaceon a heap smaller than that (64 MiB, probe). - With
offset = 1it fails sooner: the queue boundoffset + topNwraps to -2,147,483,648, so the loop that trims the candidate queue empties it, takes one more and throwsNullPointerException. That overflow dates from 3.3.0 (NoSuchElementExceptionon 3.x,NullPointerExceptionsince 4.0.0).
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).
:281:final int maxQueueSize = offset + topN;anintsum that wraps foroffset + topN >= 2^31.:271-275: after each insertionupdateNextGrouppruneswhile (queue.size() > topN)(heretopNismaxQueueSize) withqueue.pollLast()and thengroup.inQueue = false, with no null check. With a negative bound it empties theTreeSet,pollLast()returns null and:274throwsNullPointerException: Cannot assign field "inQueue" because "group" is null.:294:new ArrayList<>(topN): the result list is sized by the caller'stopN. ForInteger.MAX_VALUEandMAX - 1the 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 allocatestopNreferences, about 4 bytes each with compressed references.:298-320: the pull loop stops when the queue is empty or attopNresults, so it never needs more slots than there are groups.
| call | stock main | with 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.
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.mergeand passestopN = Integer.MAX_VALUE(orMAX - 1) for "all remaining groups": every such call throws, at any offset, however few groups exist. Withoffset >= 1, anyoffset + topN >= 2^31throwsNullPointerExceptionas soon as at least one shard holds a group (with only empty shards the pruning loop never runs, so there is noNullPointerException: the call returns no groups, or fails at the pre-size when topN itself is nearInteger.MAX_VALUE; probe). Two executed examples: each shard collected by a boundedFirstPassGroupingCollectorManager(..., 0, 10)and then merged withmerge(shardGroups, 0, Integer.MAX_VALUE, sort); and each shard collected completely (topNGroups= the shard'smaxDoc) and merged with "all". Both throw; the same merge with a finite topN returns the groups. - Who is not.
FirstPassGroupingCollectorManagerandGroupingSearchcallers with these values fail earlier, in the first-pass collector constructor:NegativeArraySizeExceptionfor (0, MAX),IllegalArgumentException "topNGroups must be >= 1 (got -2147483648)"for (1, MAX),OutOfMemoryErrorfor (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 spacewhen it does not (10^8 with a 64 MiB heap, probe). - Default vs opt-in. No setting or index is involved;
SearchGroupis@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.
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.
/*
* 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);
}
}
}
./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 withtopN = 10.- Observed
OutOfMemoryError: Requested array size exceeds VM limitfromnew ArrayList<>(topN)atSearchGroup.java:294, with assertions on and off.
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
shardsdeclaration and theAssertionError); nothing else changes, and the wrapped file passescheckGoogleJavaFormat. - 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 throwsNullPointerExceptionatSearchGroup.java:274(probe); the same fix removes it.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 release jars (4ae946e5a9b9) |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
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)
# 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)- The javadoc sets no bound on
offsetortopN, 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 + limitoverflow (SOLR-3956) and still testsInteger.MAX_VALUEas "no limit"; coreTopDocs.mergesizes by the hits available.
The full argument196 words
- No documented bound. The javadoc (
SearchGroup.java:326-331) only asks for a matchinggroupSortandfillFields=true; nothing checksoffsetortopN(: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<>()tonew 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 fortopN = Integer.MAX_VALUE(47 releases executed). - Same module, same pattern, fixed and tested. SOLR-3956 (
1ad382f3046, Chris M. Hostetter, 2013) removed anoffset + limitoverflow from grouped-facet paging; its test still loopslimitover{2, 10, 100, Integer.MAX_VALUE}(TestTermGroupFacetCollectorManager.java:106-139). TodayGroupedFacetResult.getFacetEntriessizes byMath.min(limit, facetEntries.size() - offset)(GroupedFacetResult.java:91), and coreTopDocs.mergebyMath.min(size, availHitCount - start)(TopDocs.java:313). - An internal NPE is no answer. Whatever range is intended, a
NullPointerExceptionfrom polling an empty queue after a signed overflow names an internal field, not an argument. AnIllegalArgumentExceptionwould 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.
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.
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 bygit apply,./gradlew :lucene:grouping:test -Ptests.seed=DEADBEEFpasses with assertions on and with assertions off: 15 classes, 57 tests (the repro included), 0 failures, 0 skipped;:lucene:grouping:checkGoogleJavaFormatpasses. - 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.
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.
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.
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.
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
intsize site in the grouping module. Site S6 isSearchGroup.java:281(the sum) and:294(the pre-size). Query Q6 is satisfiable at offset 1, topNInteger.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:
OutOfMemoryErrorat:294for topNMAX - 2toMAX, andNullPointerExceptionat:274for offsets 1 and 2 with topNMAX, 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).
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, incl. the migrated JIRA archive) | 2026-10-28 | two 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-28 | 14 queries | SOLR-3956 (precedent, fixed); no report |
| Mailing lists (dev@, java-user@, solr-user@) | 2026-10-28 | 10 searches | a 2012 solr-user thread with a different cause |
| Downstream projects and the web (Solr, Elasticsearch, OpenSearch, Lucene.NET, all GitHub) | 2026-10-28 | 14 downstream and port searches, 10 code searches, 13 web searches | no report |
| GitHub re-check | 2026-10-30 | 6 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-28 | nothing new; no commit or open PR touches lucene/grouping |
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) and0c43ae2b5c2(branch_6x), both 2016-06-28, changednew ArrayList<>()tonew ArrayList<>(topN).0c43ae2b5c2is not in 6.1.0; 6.2.0 is the first release with it. Swapping onlySearchGroup.javabetween 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) andc0713158f8c(branch_3x, 2011-06-16) addedSearchGroup.mergewithmaxQueueSize = offset + topN; first release 3.3.0. LUCENE-3239 (b843dfe8396, Simon Willnauer, 2011-06-30) replacedqueue.last()withpollLast(), which turned the 3.xNoSuchElementExceptioninto theNullPointerExceptionfrom 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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | OOM at :294 (assertions on and off, gradle and standalone); NPE at :274 for offset 1 |
10.6.0 (4ae946e5a9b9) | affected | OOM at :295; NPE at :275 (release jars) |
| 6.2.0 to 10.5.2 (89 releases) | affected | OOM for offset 0; NPE for offset 1 |
| 4.0.0 to 6.1.0, 5.5.3 to 5.5.5 | offset >= 1 only | offset 0 returns the groups; offset 1 throws NPE |
| 3.3.0 to 3.6.2 | offset >= 1 only | offset 0 returns the groups; offset 1 throws NoSuchElementException |
| 3.2.0 | not affected | no SearchGroup.merge method |
6.1.0 vs 6.2.0, SearchGroup.java swapped | bisect | the 6.2.0 file in 6.1.0 throws OOM; the 6.1.0 file in 6.2.0 returns the groups |
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.
| Check | Verdict | What it found |
|---|---|---|
| Independent reproducer | real | Own 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, medium | Agreed 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 severity | Built 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 confidence | Re-ran the independent reproduction's JUnit with new seeds (1A387C01 assertions on, 1A387C02 off): 3 of 4 fail each, the oracle passes. |
| Credibility audit | keep | Re-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 validated | Repro 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 fixed | The 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. |
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.
- PNG 595 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,508 words
- 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 touchedlucene/groupingupstream 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 maina9779ef3c193(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.
- 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-335a 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, :121a 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-335a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/package-info.java:76-77a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupedFacetResult.java:91a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopDocs.java:313a Lucene main checkout (645d4fe14a20) lucene/grouping/src/test/org/apache/lucene/search/grouping/TestTermGroupFacetCollectorManager.java:106-139a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:9084 (Lucene 6.2.0, Optimizations: LUCENE-7356)
- TestSearchGroupMergeAllGroups.java (JUnit, public API):
repro/LFV-958/src/org/apache/lucene/search/grouping/TestSearchGroupMergeAllGroups.java(44 lines)
The PNG is the summary card.
assets/LFV-958/bug.png(595 KB)
![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.](../assets/LFV-958/bug.png)