All findings · Lucene finding · LFV-262 · topdocs-merge-window-int-overflow

TopDocs.merge(start, topN, ...) returns a page of null hits when start + topN overflows int

TopDocs.merge(start, topN, ...) adds start + topN as an int: ask for every hit after the first with topN = Integer.MAX_VALUE and you get a page of the right length whose hits are all null, with no error. Affected since 4.8.0 (2014); Lucene's own search paths pass start = 0.

Summary

Priority
Low, rank 898TopDocs.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
minor
Since
4.8.0 (2014, when merge got its start parameter)introduced 2014-03-17 · over 12 years
Affected
4.8.0 to 10.6.0 and main
Status
documented
Found by
a Dafny model of TopDocs.merge and an exhaustive check of the real 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

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].
The minimal repro in one picture: 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.
  1. You page through merged results with TopDocs.merge(start, topN, shardHits): skip the first start hits, return up to topN. For "every hit after the first" you pass start = 1 and topN = Integer.MAX_VALUE (2,147,483,647), the usual "no limit".
  2. 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 what merge(1, 1, ...) returns.
  3. Instead you get [null]: an array of the right length, 1, with no hit in it, totalHits 2, and no exception. Code that then reads scoreDocs[0].doc throws a NullPointerException.
  4. Why: merge computes 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.
  5. Any start >= 1 with start + topN above 2,147,483,647 fails when more than start hits are available (with start or fewer the correct empty page comes back), in all four paging overloads (by score or by Sort, with or without a tie breaker); start + topN = 2,147,483,647 still works.

Who is affected

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.

Terms

  • TopDocs.merge: a static utility that combines several already sorted result lists (one per shard or slice) into one, by score or by a Sort. The paging overloads take start and topN: skip the top start hits of the merged order, then return up to topN.
  • mergeAux: the private method all paging overloads call (called from TopDocs.java:216-272, defined at :278-354).

The bug, step by step (main 645d4fe14a20)

  1. mergeAux counts the available hits, availHitCount (TopDocs.java:291-306).
  2. It sizes the page correctly: hits = new ScoreDoc[Math.min(size, availHitCount - start)] (:313). For the repro, min(2147483647, 2 - 1) = 1.
  3. It computes the loop bound in int: int requestedResultWindow = start + size; (:314). 1 + 2,147,483,647 = 2,147,483,648 does not fit and becomes -2,147,483,648; numIterOnHits = Math.min(availHitCount, requestedResultWindow) (:315) is that negative number.
  4. while (hitUpto < numIterOnHits) (:317) starts with hitUpto = 0 and exits at once, so no hit is copied into hits[hitUpto - start] (:333-335). The new array keeps its default nulls.
  5. totalHits is summed from the shards' own totals (:289-301, :348) and is right, so the result looks normal until someone reads a hit.

The numbers

callexpectedactual 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_VALUEthe remaining hitsall 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) with start >= 1 and topN > Integer.MAX_VALUE - start, when more than start hits are available (with start or fewer, TopDocs.java:310-311 returns 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.reduce calls TopDocs.merge(0, numHits, topDocs) (TopScoreDocCollectorManager.java:109), TopFieldCollectorManager.reduce calls TopDocs.merge(sort, 0, numHits, topDocs) (TopFieldCollectorManager.java:156), and AbstractKnnVectorQuery (:429) and grouping's TopGroups.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 from merge, and only null hits. Reading hit.doc or hit.score throws a NullPointerException; code that skips nulls shows an empty page while totalHits says otherwise. It never returns a wrong or extra hit.
  • Why Integer.MAX_VALUE is a natural input. Lucene commonly accepts Integer.MAX_VALUE as an upper bound meaning "all hits" in related search and collector APIs, where it is clamped to a finite limit: BooleanPerceptronClassifier calls indexSearcher.search(q.build(), Integer.MAX_VALUE) (BooleanPerceptronClassifier.java:140), IndexSearcher.searchAfter, which search(query, n) calls, caps numHits at maxDoc (IndexSearcher.java:587-597, :623-624), and the sibling TopDocsCollector.topDocs(start, howMany) accepts it. Nothing documents it as a sentinel for TopDocs.merge paging in particular.
  • Downstream, by code reading only (nothing run). Elasticsearch and OpenSearch call TopDocs.merge(from, size, ...) but check from + size in long arithmetic against index.max_result_window first, so their main search path does not reach it. Their top_hits aggregation adds from() + 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.query with group.limit=-1, which it maps to Integer.MAX_VALUE, and group.offset >= 1) appears to.
  • Likelihood: rare. A direct call with start >= 1 and a "no limit" topN is unusual, and focused searches found no public report or by-design discussion.

Minimal repro

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.

TestTopDocsMergeWindowOverflow.java (1 test)33 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;

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);
  }
}
Command
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, as TopDocs.merge(1, 1, ...) returns.
Observed
expected:<doc=1 score=1.0 shardIndex=-1> but was:<null>: a page of length 1 holding null, on main (assertions on and off) and on 10.6.0, 10.5.2, 9.12.3 and 9.1.0.

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

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.

Environment

lucenemain 645d4fe14a20: affected; the same test also fails on the 10.6.0, 10.5.2, 9.12.3 and 9.1.0 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

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

Why it looks unintended

  • The javadoc promises "topN results" for pagination with no limit, and merge itself sizes the page as min(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.txt or review comments) documents a start + topN limit.
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 on topN or start + topN is stated, and merge checks neither.
  • The method treats topN as a cap. It sizes the page as min(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 clamps howMany before it adds start (:153, :160). On a 5-doc index it returns the 4 remaining hits for (1, Integer.MAX_VALUE) where TopDocs.merge returns nulls; with the fix both agree.
  • The sibling rejects invalid input with an exception. topDocs(start, howMany) throws IllegalArgumentException for a negative start or howMany (TopDocsCollector.java:138-146). merge itself validates nothing (a negative start also leaves null slots: 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 TestTopDocsMerge draws from and size inside 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.

Suggested fix

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.

A: compute the window in long (validated by the checks) (recommended)

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.* of lucene/core with 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), upstream TestTopDocsMerge, TestMultiSliceMerge and TestTopDocsRRF passed 12 of 12, and the exhaustive harness found 0 of 4,308 big-window mismatches.

B: bound the loop by the page that was already clamped (validated by the checks)

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 test passes.
  • Not run: the full org.apache.lucene.search.* package and the verifiers' tests (A only).

C: reject an overflowing window with IllegalArgumentException (not built)

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.

Downstream note

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.

How it was found

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 mergeAux into a Dafny model (Merge.dfy). Its postcondition "the page is the sorted window and every slot is filled" verified only under the precondition start + size <= Integer.MAX_VALUE and 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, every start and size in [0, total + 1], plus size = Integer.MAX_VALUE - 1 and Integer.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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits; downstream repositories2026-10-26the 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 portonly the related items above
Open PRs2026-10-26the changed files of all 413 open apache/lucene PRsonly draft #16847 touches TopDocs.java, on other lines
Lists, JIRA, web2026-10-26dev@, java-user@, issues@ and commits@; LUCENE and SOLR JIRA (10 issues read in full); 7 web queriesno report
Recheck (GitHub)2026-10-31TopDocs.merge overflow; TopDocs merge null ScoreDoc; requestedResultWindow; TopDocs merge Integer.MAX_VALUE; mergeAux start size; the upstream file historyonly #9873 (quotes the code); upstream main 2f424fd428da still has the int sum; #16847 still an open draft

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
4.0.0 to 4.7.2 (14 releases) no such APIno 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.0the version-portable probe fails on each, score and Sort merges alike, with every control correct
10.6.0 affectedthis doc's test fails (the same lines at TopDocs.java:316-317)
main 645d4fe14a20 affectedthis doc's test fails through gradle and the standalone runner, assertions on and off; passes with fix A or B

Independent checks

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).

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidateDafny 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), proceduralCould not execute; agreed with the root cause, inputs in contract, intended argument weak.
Independent reproducer realOwn 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 verifiedThe 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.

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

Finding record

LFV-262, snapshot of 2026-11-06

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 through lucene/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 main ea57360912ed, where TopDocs.java is identical, and a main refresh on a9779ef3c193 reproduced it; a version-portable probe ran on every final release from 4.0.0 to 10.5.2.

Lucene and upstream sources

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:140
  • lucene/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), :313
  • lucene/core/src/java/org/apache/lucene/search/TopDocsCollector.java:118-122, (at 645d4fe14a20) :138-146, :153, :160
  • lucene/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)

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

Repro files

About the diagrams

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.

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.