merge(1, Integer.MAX_VALUE) over 2 hits, the window end 1 + 2,147,483,647 wrapping to -2,147,483,648, and the copy loop that never runs, leaving the page [null]. Select the still to open it at full size. Generated with AI, checked against the code.- You page through merged results with
TopDocs.merge(start, topN, shardHits): skip the firststarthits, return up totopN. For "every hit after the first" you passstart = 1andtopN = Integer.MAX_VALUE(2,147,483,647), the usual "no limit". - The input is one result list with 2 hits, doc 0 (score 2.0) and doc 1 (score 1.0). You expect the page
[doc 1], which is whatmerge(1, 1, ...)returns. - Instead you get
[null]: an array of the right length, 1, with no hit in it,totalHits2, and no exception. Code that then readsscoreDocs[0].docthrows aNullPointerException. - Why:
mergecomputes the end of the window as an int,1 + 2,147,483,647, which wraps to-2,147,483,648. The copy loop runs while its counter, starting at 0, is below that bound, so it runs 0 times. The page was already sized right,min(topN, 2 - 1) = 1, and stays empty. - Any
start >= 1withstart + topNabove 2,147,483,647 fails when more thanstarthits are available (withstartor fewer the correct empty page comes back), in all four paging overloads (by score or bySort, with or without a tie breaker);start + topN = 2,147,483,647still works.
Only code that calls a TopDocs.merge paging overload itself with start >= 1 and a huge topN (typically Integer.MAX_VALUE as "all remaining hits"), when more than start hits are available. Lucene's own search, kNN and grouping code pass start = 0. No setting or index size is needed; it then fails every time, usually as a NullPointerException.
Mechanism, terms and numbers434 words
TopDocs.merge(int start, int topN, TopDocs[] shardHits) and its Sort and tie-breaker siblings merge sorted per-shard hits into one page, skipping the top start hits. The shared mergeAux sizes the page correctly, min(topN, availableHits - start), but bounds its copy loop by start + topN computed in int. For start >= 1 and topN > Integer.MAX_VALUE - start, for example topN = Integer.MAX_VALUE as "no limit" on any page after the first, with more than start hits to merge, the sum wraps negative, the loop never runs, and merge returns a page of the right length and the right totalHits whose every hit is null, without an exception. Callers usually fail with a NullPointerException soon after. The defect came with the paging overload itself (LUCENE-5515, 2014) and is in every release from 4.8.0 through 10.6.0 and on main; the fix is two lines.
TopDocs.merge: a static utility that combines several already sorted result lists (one per shard or slice) into one, by score or by aSort. The paging overloads takestartandtopN: skip the topstarthits of the merged order, then return up totopN.mergeAux: the private method all paging overloads call (called fromTopDocs.java:216-272, defined at:278-354).
mergeAuxcounts the available hits,availHitCount(TopDocs.java:291-306).- It sizes the page correctly:
hits = new ScoreDoc[Math.min(size, availHitCount - start)](:313). For the repro,min(2147483647, 2 - 1) = 1. - It computes the loop bound in int:
int requestedResultWindow = start + size;(:314).1 + 2,147,483,647 = 2,147,483,648does not fit and becomes-2,147,483,648;numIterOnHits = Math.min(availHitCount, requestedResultWindow)(:315) is that negative number. while (hitUpto < numIterOnHits)(:317) starts withhitUpto = 0and exits at once, so no hit is copied intohits[hitUpto - start](:333-335). The new array keeps its defaultnulls.totalHitsis summed from the shards' own totals (:289-301,:348) and is right, so the result looks normal until someone reads a hit.
| call | expected | actual on main |
|---|---|---|
one list, 2 hits: merge(1, Integer.MAX_VALUE) | [doc 1] | [null] |
same list: merge(1, 1), merge(1, Integer.MAX_VALUE - 1) (controls) | [doc 1] | [doc 1] |
two shards, 5 hits: merge(1, Integer.MAX_VALUE) | 4 hits | [null, null, null, null] |
same shards: merge(3, Integer.MAX_VALUE) | 2 hits | [null, null] |
tie-breaker and Sort overloads, start 1 or 2, Integer.MAX_VALUE | the remaining hits | all null |
With start = 0 the sum cannot overflow, so the start-less merge(topN, shardHits) is safe.
Exposure and likelihood367 words
- Who is exposed. Code that calls one of the four paging overloads (
TopDocs.java:216,:228,:254,:266, all@lucene.experimental) withstart >= 1andtopN > Integer.MAX_VALUE - start, when more thanstarthits are available (withstartor fewer,TopDocs.java:310-311returns the correct empty page). Deterministic; any number of shards from 1 up; no setting or data shape needed. - Lucene's own paths are not affected.
TopScoreDocCollectorManager.reducecallsTopDocs.merge(0, numHits, topDocs)(TopScoreDocCollectorManager.java:109),TopFieldCollectorManager.reducecallsTopDocs.merge(sort, 0, numHits, topDocs)(TopFieldCollectorManager.java:156), andAbstractKnnVectorQuery(:429) and grouping'sTopGroups.merge(TopGroups.java:241,:247) use the start-less overloads. - What the caller sees. A page of the right length and the right
totalHits, no exception frommerge, and onlynullhits. Readinghit.docorhit.scorethrows aNullPointerException; code that skips nulls shows an empty page whiletotalHitssays otherwise. It never returns a wrong or extra hit. - Why
Integer.MAX_VALUEis a natural input. Lucene commonly acceptsInteger.MAX_VALUEas an upper bound meaning "all hits" in related search and collector APIs, where it is clamped to a finite limit:BooleanPerceptronClassifiercallsindexSearcher.search(q.build(), Integer.MAX_VALUE)(BooleanPerceptronClassifier.java:140),IndexSearcher.searchAfter, whichsearch(query, n)calls, capsnumHitsatmaxDoc(IndexSearcher.java:587-597,:623-624), and the siblingTopDocsCollector.topDocs(start, howMany)accepts it. Nothing documents it as a sentinel forTopDocs.mergepaging in particular. - Downstream, by code reading only (nothing run). Elasticsearch and OpenSearch call
TopDocs.merge(from, size, ...)but checkfrom + sizein long arithmetic againstindex.max_result_windowfirst, so their main search path does not reach it. Theirtop_hitsaggregation addsfrom() + size()in int before widening it (the pattern behind elastic/elasticsearch#158635), but builds the shard collector from the same wrapped int, which Lucene's collector managers reject (numHits <= 0,TopScoreDocCollectorManager.java:62-64,TopFieldCollectorManager.java:62-64), so when a shard has a match that path fails earlier and does not reach this bug. Solr's distributed grouping (group.querywithgroup.limit=-1, which it maps toInteger.MAX_VALUE, andgroup.offset >= 1) appears to. - Likelihood: rare. A direct call with
start >= 1and a "no limit"topNis unusual, and focused searches found no public report or by-design discussion.
One JUnit test in lucene/core, public API only: a TopDocs with 2 hits and one call, TopDocs.merge(1, Integer.MAX_VALUE, ...), compared with the hand-computed page [doc 1]. No randomness; fails with assertions on and off, no build change needed.
/*
* 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;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestTopDocsMergeWindowOverflow extends LuceneTestCase {
public void testMergeSecondPageWithMaxValueTopN() {
ScoreDoc first = new ScoreDoc(0, 2f);
ScoreDoc second = new ScoreDoc(1, 1f);
TopDocs shard =
new TopDocs(new TotalHits(2, TotalHits.Relation.EQUAL_TO), new ScoreDoc[] {first, second});
// expected: [second], as merge(1, 1, ...) returns; actual: [null] (start + topN overflows int)
assertArrayEquals(
new ScoreDoc[] {second},
TopDocs.merge(1, Integer.MAX_VALUE, new TopDocs[] {shard}).scoreDocs);
}
}
cd $LUCENE # an apache/lucene checkout at 645d4fe14a20 (or a later commit where TopDocs.java is unchanged)
cp $REPRO/TestTopDocsMergeWindowOverflow.java lucene/core/src/test/org/apache/lucene/search/
./gradlew :lucene:core:test --tests org.apache.lucene.search.TestTopDocsMergeWindowOverflow -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false
- Expected
[doc=1 score=1.0], the second hit, asTopDocs.merge(1, 1, ...)returns.- Observed
expected:<doc=1 score=1.0 shardIndex=-1> but was:<null>: a page of length 1 holdingnull, on main (assertions on and off) and on 10.6.0, 10.5.2, 9.12.3 and 9.1.0.
More files, environment, full output209 words
The test sits in org.apache.lucene.search, next to upstream's TestTopDocsMerge; it uses only public classes (TopDocs, ScoreDoc, TotalHits). The test body draws no random values, and the path it tests uses none of the settings LuceneTestCase randomizes (codec, locale, time zone), so the result does not depend on the seed (it fails with DEADBEEF, 5EED2621 and 5EED2622).
The same file fails unchanged on the 10.6.0, 10.5.2, 9.12.3 and 9.1.0 release jars (assertions on). Older releases need small edits (before 9.1.0 the test framework is in another package, before 8.0.0 there is no TotalHits, and 4.8.0 to 5.0.0 and 6.5.0 to 8.11.4 have a different paging merge signature); a version-portable probe covers them (see the version history).
It is also the suggested regression test, as written.
| lucene | main 645d4fe14a20: affected; the same test also fails on the 10.6.0, 10.5.2, 9.12.3 and 9.1.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
TestTopDocsMergeWindowOverflow > testMergeSecondPageWithMaxValueTopN FAILED
arrays first differed at element [0]; expected:<doc=1 score=1.0 shardIndex=-1> but was:<null>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:E9808A3EC0C1DC45]:0)
at app//org.junit.internal.ComparisonCriteria.arrayEquals(ComparisonCriteria.java:78)
at app//org.junit.internal.ComparisonCriteria.arrayEquals(ComparisonCriteria.java:28)
at app//org.junit.Assert.internalArrayEquals(Assert.java:534)
at app//org.junit.Assert.assertArrayEquals(Assert.java:285)
at app//org.junit.Assert.assertArrayEquals(Assert.java:300)
at app//org.apache.lucene.search.TestTopDocsMergeWindowOverflow.testMergeSecondPageWithMaxValueTopN(TestTopDocsMergeWindowOverflow.java:29)
# the same with -Ptests.asserts=false; the standalone runner: 1 of 1 failed on main (-ea, -da), 10.6.0, 10.5.2, 9.12.3, 9.1.0- The javadoc promises "topN results" for pagination with no limit, and
mergeitself sizes the page asmin(topN, hits - start): the length is right, only the hits are missing. - The sibling
TopDocsCollector.topDocs(start, howMany)clamps before adding and returns the remaining hits for(1, Integer.MAX_VALUE). - Nothing (javadoc, tests,
CHANGES.txtor review comments) documents astart + topNlimit.
The full argument240 words
- The contract. "Returns a new TopDocs, containing topN results across the provided TopDocs" (
TopDocs.java:197) and "Same as merge(int, TopDocs[]) but also ignores the top start top docs. This is typically useful for pagination." (:208-209). No bound ontopNorstart + topNis stated, andmergechecks neither. - The method treats
topNas a cap. It sizes the page asmin(topN, availHitCount - start)(:313). The introducing commit's message says the point was a "merged ScoreDoc array with length of at most equal to the specified size". - The sibling handles the same input.
TopDocsCollector.topDocs(start, howMany)documents that only the available documents are returned (TopDocsCollector.java:118-122) and clampshowManybefore it addsstart(:153,:160). On a 5-doc index it returns the 4 remaining hits for(1, Integer.MAX_VALUE)whereTopDocs.mergereturns nulls; with the fix both agree. - The sibling rejects invalid input with an exception.
topDocs(start, howMany)throwsIllegalArgumentExceptionfor a negativestartorhowMany(TopDocsCollector.java:138-146).mergeitself validates nothing (a negativestartalso leavesnullslots: over 2 hits,merge(-1, 1)returns[null]), a separate input-validation question; but(1, Integer.MAX_VALUE)is valid input, which the sibling accepts and answers with the remaining hits. - No test pins it. Upstream
TestTopDocsMergedrawsfromandsizeinside the hit count (TestTopDocsMerge.java:123-124,:279-280,:302-303); the LUCENE-5515 review discussed<versus<=and javadoc wording, never large sizes.
The devil's-advocate check134 words
The intended-behaviour checker built the strongest case: the API is @lucene.experimental; Lucene's own callers always pass start = 0; LUCENE-5515 moved the caller's from + size sum into merge, and callers had bounded that sum themselves; grouping's TopGroups still adds docOffset + docTopN in int. So Lucene "assumes" start + topN fits in an int.
Why it fails: nothing (javadoc, CHANGES.txt, MIGRATE.md, a test or a review comment) states that bound; @lucene.experimental allows the API to change, not a page of nulls; merge itself treats topN as an upper bound, and the sibling topDocs(start, howMany) handles the same pair. Every verifier and the adjudication said real; the one cross-model "not real" was procedural (it could not run code) and rated the intended argument weak. Verdict: not intended, a defect.
Recommended A: compute the window in long
Compute the window in long, two lines in TopDocs.mergeAux: long requestedResultWindow = (long) start + size; and int numIterOnHits = (int) Math.min(availHitCount, requestedResultWindow);. The repro passes, the org.apache.lucene.search tests of lucene/core give 2,340 tests, 0 failures, 49 skipped, and the module's lint checks pass.
- int requestedResultWindow = start + size;
- int numIterOnHits = Math.min(availHitCount, requestedResultWindow);
+ long requestedResultWindow = (long) start + size;
+ int numIterOnHits = (int) Math.min(availHitCount, requestedResultWindow);All options and trade-offs3 options · 505 words
Two options were built and validated through gradle on 645d4fe14a20; a third is listed, not built. Workaround until fixed (for start >= 0 and topN >= 0): clamp before calling, TopDocs.merge(start, (int) Math.min(topN, (long) Integer.MAX_VALUE - start), shardHits), or pass the number of hits left after start (available hits minus start), not Integer.MAX_VALUE and not the total number of available hits, which can itself make start + topN overflow.
2 lines in TopDocs.mergeAux (:314-315). numIterOnHits is at most availHitCount, an int, so the cast is safe. Independent verifiers wrote the same change.
- int requestedResultWindow = start + size;
- int numIterOnHits = Math.min(availHitCount, requestedResultWindow);
+ long requestedResultWindow = (long) start + size;
+ int numIterOnHits = (int) Math.min(availHitCount, requestedResultWindow);Trade-offs
None known; no behaviour change for any start + topN <= Integer.MAX_VALUE.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, seed DEADBEEF: the repro fails on stock and passes with A, assertions on and off. - The closest suites with A and the repro (
TestTopDocsMerge,TestMultiSliceMerge,TestTopDocsRRF,TestSortOptimization,TestTopDocsCollector,TestTopFieldCollector): 7 suites, 72 tests, 0 failures. - Every test in
org.apache.lucene.search.*oflucene/corewith A and the repro: 215 suites, 2,340 tests, 0 failures, 49 skipped. :lucene:core:check -x test(checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMain,forbiddenApisTest): passes.- Earlier, the same change made every verifier's test pass on
ea57360912ed(the finder's 2, the independent reproduction's 7, the cross-model review's 5, the intended-behaviour check's probe), upstreamTestTopDocsMerge,TestMultiSliceMergeandTestTopDocsRRFpassed 12 of 12, and the exhaustive harness found 0 of 4,308 big-window mismatches.
1 line: int numIterOnHits = start + hits.length;. Since hits.length = min(size, availHitCount - start), the sum is at most availHitCount and equals the old bound whenever that fits. This is the clamp-before-add pattern of TopDocsCollector.topDocs(start, howMany) (TopDocsCollector.java:153, :160).
- int requestedResultWindow = start + size;
- int numIterOnHits = Math.min(availHitCount, requestedResultWindow);
+ // hits.length <= availHitCount - start, so this sum cannot overflow
+ int numIterOnHits = start + hits.length;Trade-offs
One line and no long arithmetic, but it ties the loop bound to the array length, which a reviewer has to see is equivalent (hence the comment). A is the one every verifier ran.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, seed DEADBEEF: the repro passes with B, assertions on and off. - The same 7 closest suites: 72 tests, 0 failures;
:lucene:core:check -x testpasses. - Not run: the full
org.apache.lucene.search.*package and the verifiers' tests (A only).
Validate start + topN and throw.
Trade-offs
Loud instead of silent, but Integer.MAX_VALUE as "no limit" is a legitimate input that the sibling topDocs(start, howMany) accepts; callers would have to clamp themselves. A or B gives the documented result instead.
By code reading only (not run), Solr's distributed grouping with group.limit=-1 and group.offset >= 1 calls merge(start, Integer.MAX_VALUE, ...) and would fail with a NullPointerException; Elasticsearch and OpenSearch bound from + size before they merge.
While writing a Dafny model of TopDocs.merge, an AI finder found that the property "every returned slot holds a hit" verifies only if start + topN fits in an int. An exhaustive check of the real jar then found 1,726,010 null-slot results in 3,118,984 large-window checks and none in 12,265,680 in-range checks.
The method in full194 words
Method: semi-formal (a Dafny model plus bounded-exhaustive translation validation of the real jar).
- The finder, an AI tool, transcribed
mergeAuxinto a Dafny model (Merge.dfy). Its postcondition "the page is the sorted window and every slot is filled" verified only under the preconditionstart + size <= Integer.MAX_VALUEand failed without it (a designed counterexample,MergeAuxNoWindowBound); Dafny reports 30 verified and 2 errors, both designed (this counterexample and a separate tie-breaker check,TwoShardsTie); 3 of 3 vacuity mutants killed. So the hypothesis came from reading the code while modelling it, not from a blind search. - A bounded-exhaustive harness then ran the stock main jar against "sort the union, take
[start, start + size)": every shape up to 3 shards x 3 tied hits, everystartandsizein[0, total + 1], plussize=Integer.MAX_VALUE - 1andInteger.MAX_VALUE. With assertions on and off: 0 mismatches in 12,265,680 in-range checks, 1,726,010 null-slot mismatches in 3,118,984 big-window checks. - The finder then wrote a public-API JUnit test; independent reproducers wrote their own tests from the claim alone. The one-test repro was cut from the finder's test on 2026-10-29.
No prior report: 181 recorded searches over GitHub issues, PRs and commits, the mailing lists, JIRA, downstream projects and the web, plus the files of all 413 open PRs (2026-10-26), rechecked on 2026-10-31. Closest: LUCENE-5515 (#6578), which introduced the code.
Searches4 searches
Verdict new: nobody reports or discusses TopDocs.merge overflowing start + topN or returning null hits for a large topN on a later page, and no fix exists. Related, not reports of this bug: #6578 (LUCENE-5515, the introducing change; nobody discussed large sizes); apache/lucene-solr#769 (LUCENE-8905, argument checks for the sibling TopDocsCollector.topDocs(start, howMany); overflow not mentioned); #9873 (LUCENE-8829) and #9900 (LUCENE-8857), tie-break refactors whose patches quote the window lines; #10342 (LUCENE-9302, a different int overflow in grouping totals); elastic/elasticsearch#158635 (open, the same int-sum pattern in that project's own validation, not in Lucene). Open draft #16847 changes other lines of TopDocs.java; a fix may need a rebase. Gaps: GitHub search was rate-limited and token-based; PR review comments are not indexed; list and JIRA search are keyword-based; Solr's own lists were not searched directly.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits; downstream repositories | 2026-10-26 | the scripted prior-report search in five runs (focused symptom, identifier and window phrasings), hand-run GitHub reads of the related threads, the old lucene-solr repository, Elasticsearch, OpenSearch, Solr and the Lucene.NET port | only the related items above |
| Open PRs | 2026-10-26 | the changed files of all 413 open apache/lucene PRs | only draft #16847 touches TopDocs.java, on other lines |
| Lists, JIRA, web | 2026-10-26 | dev@, java-user@, issues@ and commits@; LUCENE and SOLR JIRA (10 issues read in full); 7 web queries | no report |
| Recheck (GitHub) | 2026-10-31 | TopDocs.merge overflow; TopDocs merge null ScoreDoc; requestedResultWindow; TopDocs merge Integer.MAX_VALUE; mergeAux start size; the upstream file history | only #9873 (quotes the code); upstream main 2f424fd428da still has the int sum; #16847 still an open draft |
Introduced by 1fd9722f495 (LUCENE-5515, 2014-03-17), which added the start parameter and the int sum together; first released in 4.8.0. Executed: all 116 releases from 4.8.0 to 10.5.2, 10.6.0 and main fail; 4.0.0 to 4.7.2 have no paging overload. Not fixed upstream.
Versions tested4 rows
Introduced by 1fd9722f4956 on trunk, "LUCENE-5515: Improved TopDocs#merge to create a merged ScoreDoc array with length of at most equal to the specified size instead of length equal to at most from + size as was before." (Martijn van Groningen, 2014-03-17; a JIRA patch reviewed by Mike McCandless, no pull request; the issue is now #6578), backported to branch_4x the same day as 2e4dd1f1e76. Before it, merge had no start offset and callers sliced from + size hits themselves, so it is not a regression. LUCENE-6220 (2015, the shared mergeAux), LUCENE-6878, LUCENE-7707 and LUCENE-8857 (2019, the tie-breaker overloads) kept the two lines unchanged. Every run used JDK 25.0.3, not the JVM the older releases targeted; the controls pass in each run. Upstream main 2f424fd428da (2026-10-30) still has the lines at TopDocs.java:314-315.
| Version or commit | Verdict | Result |
|---|---|---|
| 4.0.0 to 4.7.2 (14 releases) | no such API | no merge overload with a start parameter; the 4.8-era repro does not compile on 4.7.2 |
| 4.8.0 to 10.5.2 (116 releases) | first: 4.8.0 | the version-portable probe fails on each, score and Sort merges alike, with every control correct |
| 10.6.0 | affected | this doc's test fails (the same lines at TopDocs.java:316-317) |
| main 645d4fe14a20 | affected | this doc's test fails through gradle and the standalone runner, assertions on and off; passes with fix A or B |
An independent reproducer, an intended-behaviour devil's advocate, a review by another model family (OpenAI GPT-5.6) and an adjudication all rated it real; the adjudication confirmed it with high confidence. The one-test repro still fails on main 645d4fe14a20.
Checks and verdicts288 words
Before the finding was recorded, a read-only refute review by OpenAI GPT-5.6 returned "not real" for a procedural reason only (it could not run Java); it agreed with the root cause and rated the intended-behaviour argument weak, and a re-run of the finder's repro failed (2 of 2).
| Check | Verdict | What it found |
|---|---|---|
| Finder | candidate | Dafny model and exhaustive harness (see How it was found); JUnit fails 2 of 2 on main (assertions on and off), 10.5.2 and 9.12.3; passes with the fix. |
| Refute review (OpenAI GPT-5.6, read-only) | not real (low), procedural | Could not execute; agreed with the root cause, inputs in contract, intended argument weak. |
| Independent reproducer | real | Own public-API test with a hand-computed oracle: 4 of 7 fail on main (assertions on and off), 10.5.2 and 9.12.3; 3 controls pass, including merge(1, MAX - 1); its own two-line fix passes 7 of 7. |
| Intended lens (OpenAI GPT-5.6, read-only) | real (medium) | Intended argument weak; a separate test fails 3 of 5 with both controls passing on main, 10.5.2 and 9.12.3, and passes 5 of 5 with the fix. |
| Devil's advocate | real (high that it is a bug, low severity) | Built the strongest intended case and rejected it; its probe shows TopDocsCollector.topDocs(1, MAX) returning the 4 hits where TopDocs.merge(1, MAX) returns nulls. |
| Adjudication | confirmed (high) | Re-ran the independent reproduction's test (4 of 7 fail; 7 of 7 with the fix) and the finder's (assertions off, 2 of 2 fail); impact class minor, security none. |
| Repro minimization and this doc | verified | The one-test repro fails on main 645d4fe14a20 (assertions on and off, three seeds), 10.6.0, 10.5.2, 9.12.3 and 9.1.0, and passes with fix A or B. |
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 501 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,282 words
- Registry title
- TopDocs.merge(start, topN, ...) returns a page of null ScoreDocs, with no error, when start + topN overflows int (for example start = 1 and topN = Integer.MAX_VALUE meaning 'no limit')
- Area
- core / search: TopDocs.merge paging window (mergeAux; score, Sort and tie-breaker overloads)
- Priority
- Low, rank 898: TopDocs.merge silently returns null ScoreDocs when start + topN overflows, as with an Integer.MAX_VALUE 'no limit', which is the same MAX_VALUE-as-limit boundary class as LFV-182, and callers usually fail with an NPE soon after.
- Impact class
- minor
- Impact note
- Only code that calls the public, @lucene.experimental TopDocs.merge paging overloads (score, Sort or custom tie breaker) with start >= 1 and topN > Integer.MAX_VALUE - start, typically topN = Integer.MAX_VALUE as 'no limit' on a page after the first, is hit; merge then returns an array of the right length with the right totalHits in which every slot is null, throws nothing itself, and callers usually fail soon after with a NullPointerException rather than show wrong hits; no Lucene search path passes start > 0, no setting or index size is needed, and every release from 4.8.0 through 10.6.0 and main is affected.
- Summary
- You merge two shards' results (5 hits in total) with TopDocs.merge(1, Integer.MAX_VALUE, shards) to get every hit after the first, and expect the 4 remaining hits in score order, the same as TopDocs.merge(1, 4, shards) returns. Instead you get an array of length 4 in which every entry is null, and no exception: merge adds start + topN in int, the sum wraps to a negative number, and the loop that copies hits never runs. The Sort and tie-breaker overloads behave the same way; start + topN = 2^31 - 1 still works and 2^31 fails.
- Introduced
- 1fd9722f4956bf557bc7d9e7ca4734559b80d035 (LUCENE-5515 'Improve TopDocs#merge for pagination', by Martijn van Groningen (@martijnvg), reviewed by Mike McCandless (@mikemccand), SVN r1578262 on trunk with no pull request; issue now apache/lucene#6578; branch_4x backport 2e4dd1f1e76, r1578267, the same day, which 4.8.0 to 5.5.5 contain) [read: git log -S requestedResultWindow and git blame of TopDocs.java:310-317 in the full-history clone; exec: the 4.8-era repro fails on 4.8.0 and does not compile on 4.7.2, which has no paging overload]. The commit added the start parameter and the int sum start + size together, so the defect is as old as the paging API; not a regression. Later changes kept the two lines unchanged (LUCENE-6220 e4436aa7fec, LUCENE-6878 724e803e195, LUCENE-7707 2e56c0e5056, and LUCENE-8857 f686167e710, 2019, which added the custom tie-breaker overloads) (2014-03-17)
- First affected version
- 4.8.0 [exec] (released 2014-04-27; the first release with a start parameter on TopDocs.merge; the 4.8-era repro fails with -ea and -da and the portable probe fails); 4.0.0 to 4.7.2 have no merge overload with a start parameter and are not affected [exec: the portable probe reports the API absent on all 14 of them, and the 4.8-era repro does not compile on 4.7.2]
- Affected versions
- every release from 4.8.0 through 10.5.2 (116 releases), 10.6.0 [exec: the one-test repro fails on the 10.6.0 jars with -ea and -da, 2026-10-31] and main ea57360912ed; not fixed in any release, and the int sum is still at TopDocs.java:314 on upstream main e1c4184cb983 (read 2026-10-26) [exec: the portable probe fails on all 116 releases from 4.8.0 to 10.5.2, including 8.8.2 to 8.11.4 run from SHA-1-verified release jars because the history clone has no tags for them, and on main, with every control correct; the independent reproduction's public-API JUnit fails 4 of 7 on main (-ea, -da), 10.5.2 and 9.12.3; the finder's JUnit fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3; read: TopDocs.java:313-317 blame to 1fd9722f495]. Caveat: every run used JDK 25.0.3, not the JVM the older releases targeted (the controls pass in each run)
- 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: Dafny, exhaustive check
- Upstream and prior reports
- none found (2026-10-26, the prior-report record plus the finder's scripted prior-report search: 181 recorded queries, namely the scripted prior-report search over apache/lucene issues and PRs (32), commits (6), downstream projects (4), the dev@, java-user@, issues@ and commits@ lists (76), the LUCENE JIRA archive (15) and the web (6), plus hand-run GitHub reads (8), JIRA REST reads (16), a list-thread read, downstream source reads (3), manual web searches (8) and the local history clone (4); the changed files of all 413 open apache/lucene PRs; 21 hits read and classified, none the same bug; the intended-behaviour check ran its own prior-art search (4 GitHub, 16 list and 3 JIRA queries) and also found no report, won't-fix or by-design discussion); not fixed upstream (the int sum is still at TopDocs.java:314 on upstream main e1c4184cb983, read 2026-10-26; the last commit touching TopDocs.java on main is 39549329509, #14873, 2025-07-03); closest items, not reports: #6578 / LUCENE-5515 (the introducing change; nobody discussed large sizes), apache/lucene-solr PR #769 / LUCENE-8905 (argument checks for the sibling TopDocsCollector.topDocs(start, howMany), which already clamps howMany before adding start; overflow not mentioned), #9873 / LUCENE-8829 and #9900 / LUCENE-8857 (tie-break refactors whose patches quote the window lines), #16847 (open draft; edits other lines of TopDocs.java), and an open downstream PR in Elasticsearch, elastic/elasticsearch#158635 (the same int-sum pattern in that project's own top_hits and inner_hits validation, not in Lucene)
- Lucene commit tested
- main
645d4fe14a20(the one-test repro, standalone and through gradle, assertions on and off; fixes A and B throughlucene/core, 2026-10-31); the 10.6.0, 10.5.2, 9.12.3 and 9.1.0 release jars (the same test). The hunt, the verifiers and the adjudication ran on mainea57360912ed, whereTopDocs.javais identical, and a main refresh ona9779ef3c193reproduced it; a version-portable probe ran on every final release from 4.0.0 to 10.5.2.
- Finding record
lucene/core/src/java/org/apache/lucene/search/TopDocs.java:196-218 (at ea57360912ed) (merge(int topN, ...): 'Returns a new TopDocs, containing topN results across the provided TopDocs'; merge(int start, int topN, ...): 'Same as merge(int, TopDocs[]) but also ignores the top start top docs. This is typically useful for pagination.'), :201, :214, :226, :239, :252, :264 (@lucene.experimental), :220-271 (tie-breaker and Sort overloads, all through mergeAux)lucene/core/src/java/org/apache/lucene/search/TopDocs.java:278-354 (at ea57360912ed) (mergeAux): :313 hits = new ScoreDoc[Math.min(size, availHitCount - start)] (right length), :314 int requestedResultWindow = start + size (wraps), :315 numIterOnHits = Math.min(availHitCount, requestedResultWindow) (negative), :317 while (hitUpto < numIterOnHits) (never runs)lucene/core/src/java/org/apache/lucene/search/TopDocsCollector.java:119-125 (at ea57360912ed) (topDocs(start, howMany) javadoc: only the available documents are returned), :153 (howMany = Math.min(size - start, howMany) before :160 start + howMany)lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollectorManager.java:109, (at ea57360912ed) TopFieldCollectorManager.java:156, AbstractKnnVectorQuery.java:429 (in-tree callers use start 0); lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroups.java:241, :247 (a separate docOffset + docTopN int sum, not covered)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/search/TopDocs.java:216-272, (at 645d4fe14a20) :278-354 (mergeAux: :291-306, :313, :314, :315, :317, :333-335, :289-301, :348)
- Who is affected
lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollectorManager.java:109; (at 645d4fe14a20) TopFieldCollectorManager.java:156; AbstractKnnVectorQuery.java:429; IndexSearcher.java:587-597; IndexSearcher.java:623-624; TopScoreDocCollectorManager.java:62-64; TopFieldCollectorManager.java:62-64; lucene/classification/src/java/org/apache/lucene/classification/BooleanPerceptronClassifier.java:140lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroups.java:241, (at 645d4fe14a20) :247
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/TopDocs.java:196-218 (at 645d4fe14a20) (javadoc), :214, :226, :252, :264 (@lucene.experimental), :313lucene/core/src/java/org/apache/lucene/search/TopDocsCollector.java:118-122, (at 645d4fe14a20) :138-146, :153, :160lucene/core/src/test/org/apache/lucene/search/TestTopDocsMerge.java:123-124, (at 645d4fe14a20) :279-280, :302-303
- History
a Lucene clone: git show releases/lucene/10.6.0:lucene/core/src/java/org/apache/lucene/search/TopDocs.java (:316-317)
- Diagrams
lucene/core/src/java/org/apache/lucene/search/TopDocs.java:313-317 (at 645d4fe14a20)
- TestTopDocsMergeWindowOverflow.java (1 test):
repro/LFV-262/src/org/apache/lucene/search/TestTopDocsMergeWindowOverflow.java(33 lines)
A picture of the minimal repro and the int overflow, made in parallel with this doc from the code at 645d4fe14a20; the PNG is the summary still.
assets/LFV-262/bug.png(501 KB)
![Diagram of the minimal repro. One result list holds 2 hits, doc 0 with score 2.0 and doc 1 with score 1.0. TopDocs.merge(1, Integer.MAX_VALUE) sizes the page as min(2147483647, 2 - 1) = 1 slot, but computes the window end 1 + 2147483647 in int, which wraps to -2147483648; the copy loop runs while 0 < -2147483648, which is false, so the slot stays null. Expected page: [doc 1]; actual page: [null].](../assets/LFV-262/bug.png)