All findings · Lucene finding · LFV-997 · sortrescorer-collector-presized-by-topn

SortRescorer and Expression#getRescorer pre-allocate their sort queue for topN instead of for the first-pass hits, so topN = Integer.MAX_VALUE throws and a large topN costs memory in proportion to topN

Re-sorting 5 hits with new SortRescorer(sort).rescore(searcher, hits, Integer.MAX_VALUE) throws OutOfMemoryError on main (IllegalArgumentException in 7.5.0 to 10.6.0), while QueryRescorer returns the 5 hits. The sort queue is sized by topN, not by the hit count: 120 MB per call at topN = 10^7.

Summary

Priority
Low, rank 391The core SortRescorer and Expression#getRescorer fail loudly on the topN = Integer.MAX_VALUE 're-sort all' idiom and use memory in proportion to topN, but results are always correct when the call succeeds, which is the same core MAX_VALUE pre-size profile as the kNN row LFV-454 with a less common API.
Impact
wrong error
Since
4.8.0 (LUCENE-5545, 2014)introduced 2014-03-23 · over 12 years
Affected
4.8.0 to 10.6.0 and main
Status
documented
Found by
a planning probe, then a differential harness and a JUnit

AI-assisted page: this finding was first signalled by code reading or testing 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

SortRescorer re-sorts a list of hits it is given, so it can never return more than that many. Yet it sizes its sort collector by topN, and the collector reserves arrays of length topN up front. Ask for Integer.MAX_VALUE ("all of them") and it tries to reserve about 2 billion slots for 5 hits.

Background: rescoring

  1. A first-pass search returns the best hits as TopDocs, for example the top 1,000 by BM25.
  2. A Rescorer re-orders those hits: rescore(searcher, firstPassTopDocs, topN) returns at most topN of them. QueryRescorer mixes in a second query, DoubleValuesSourceRescorer a per-document value, and SortRescorer (and Expression#getRescorer) re-sorts them by a Sort.
  3. Lucene's top-N collectors reserve their N slots up front: TopFieldCollectorManager documents that its collectors "pre-allocate a full array of length numHits".
Summary card: 5 first-pass hits re-sorted by price on Lucene main. Expected: the 5 hits, prices 6 to 10. Actual with topN = Integer.MAX_VALUE: OutOfMemoryError, because SortRescorer sizes its sort collector by topN instead of by the 5 hits, and the collector pre-allocates arrays of length topN; a large topN costs about 12 bytes per slot on every call. The fix sizes the collector with min(topN, max(1, hits.length)).
The diagram follows the 5-hit example on main 645d4fe14a20: the first pass, the collector sized by topN, the OutOfMemoryError, the memory of one call against topN, and the fix. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 5 documents with a price doc value (10, 9, 8, 7, 6) and run a first-pass search that returns all 5.
  2. Re-sort them by price with new SortRescorer(sort).rescore(searcher, firstPass, Integer.MAX_VALUE), meaning "all of them". You expect the 5 hits, prices [6, 7, 8, 9, 10]: what topN = 5 or 10 returns, and what QueryRescorer and DoubleValuesSourceRescorer return for any topN >= 0.
  3. Instead main throws OutOfMemoryError: Requested array size exceeds VM limit, and 7.5.0 to 10.6.0 throw IllegalArgumentException: maxSize must be >= 0 and < 2147483631.
  4. Why: rescore builds its sort collector for topN hits (SortRescorer.java:48-49), and the collector reserves arrays of that length up front, although it only ever receives the 5 hits it is given.
  5. Below the array limit the call can succeed but pays for topN (about 12 bytes per slot): 1,840 bytes at topN = 5, 120,001,784 bytes (120 MB) at topN = 10^7, for the same 5 hits. Expression#getRescorer inherits the same code.

Who is affected

Applications that re-sort first-pass hits with SortRescorer or Expression#getRescorer and pass a topN far above the hit count, typically Integer.MAX_VALUE for "all". No setting needed, any index size, every release since 4.8.0. Loud at the extremes; below them, silent memory in proportion to topN. Rare: the documented use keeps topN below the hit count.

Mechanism, terms and numbers755 words

SortRescorer.rescore(searcher, firstPassTopDocs, topN) re-sorts the hits it is given, so it can never return more than firstPassTopDocs.scoreDocs.length of them. It still builds its TopFieldCollector with numHits = topN (SortRescorer.java:48-49 on main 645d4fe14a20), and that collector pre-allocates arrays of length numHits (TopFieldCollectorManager.java:44-45). So topN = Integer.MAX_VALUE, a common way to say "re-sort all of them" (it works with the sibling rescorers and IndexSearcher), throws on 5 hits: OutOfMemoryError: Requested array size exceeds VM limit on main, IllegalArgumentException: maxSize must be >= 0 and < 2147483631 in 7.5.0 to 10.6.0 (NegativeArraySizeException in 4.9.1 to 7.4.0). A topN that fits the array limit but not the heap throws OutOfMemoryError: Java heap space, and below that every call allocates about 12 bytes per topN slot for one long sort field, whatever the hit count: 120 MB for 5 hits at topN = 10^7. QueryRescorer and DoubleValuesSourceRescorer return min(topN, hits) for any topN >= 0, and IndexSearcher caps numHits at maxDoc. Expression#getRescorer returns a subclass that inherits rescore(). Every release since 4.8.0 (2014) has it. In these topN >= hits.length calls the returned hits, order and sort values are correct whenever the call succeeds (a smaller topN hits the separate LFV-424). Fix (one new line, one changed line): size the collector with Math.min(topN, Math.max(1, hits.length)).

Terms

  • Rescorer: Rescorer#rescore(IndexSearcher searcher, TopDocs firstPassTopDocs, int topN), documented as "@param topN How many re-scored hits to return" (Rescorer.java:40), with no bound. Rescorer is @lucene.experimental (:30).
  • SortRescorer: "A Rescorer that re-sorts according to a provided Sort" (SortRescorer.java:27). Expression#getRescorer(bindings) returns an ExpressionRescorer (Expression.java:112-114), which extends SortRescorer, never overrides rescore() and is documented as "Functionally ... the same as SortRescorer" (ExpressionRescorer.java:30-37).
  • Pre-allocation: "The instances returned by this method pre-allocate a full array of length numHits" (TopFieldCollectorManager.java:44-45). For a long sort field that is new long[numHits] (LongComparator.java:38), plus the queue's new Object[maxSize + 1] (PriorityQueue.java:129-134).

The bug

  1. rescore clones the first-pass hits and sorts them by doc id (SortRescorer.java:42-44), so hits.length is known.
  2. It then builds new TopFieldCollectorManager(sort, topN, null, Integer.MAX_VALUE).newCollector() (SortRescorer.java:48-49): the size is the caller's topN.
  3. newCollector() calls FieldValueHitQueue.create(sort.getSort(), numHits) (TopFieldCollectorManager.java:112-114), which builds one comparator of that size per sort field (FieldValueHitQueue.java:139-151) and then the queue (:153), whose guard rejects maxSize >= ArrayUtil.MAX_ARRAY_LENGTH with IllegalArgumentException (PriorityQueue.java:121-124, "Throw exception to prevent confusing OOME").
  4. The loop then feeds only the hits.length hits (SortRescorer.java:60-82); :86 even asserts hits.length == rescoredDocs.scoreDocs.length. Every slot beyond hits.length is never used.
  5. On main, #14873 (unreleased) builds the comparators before the queue's guard runs, so new long[Integer.MAX_VALUE] fails first with OutOfMemoryError; that ordering is the separate bug LFV-993 (different fix site). Releases reach the guard and throw IllegalArgumentException.

The numbers

5 hits, prices 10, 9, 8, 7, 6, one long sort field, main 645d4fe14a20, -Xmx1g. Bytes allocated by one SortRescorer.rescore call: 1,840 at topN = 5; 1,904 at 10; 13,784 at 1,000; 12,001,784 at 10^6; 120,001,784 at 10^7: about 12 bytes per topN slot (in this probe exactly 1,784 + 12 x topN for even topN, 4 bytes less for odd topN such as 5, from the 8-byte alignment of the queue's Object[topN + 1]; the constant depends on the index, and the figures are for one JVM with compressed references). topN = 2,147,483,630 throws OutOfMemoryError: Java heap space; Integer.MAX_VALUE throws OutOfMemoryError: Requested array size exceeds VM limit. With the fix: 1,840 bytes at every topN, the 5 hits always returned. QueryRescorer (12,856 bytes) and DoubleValuesSourceRescorer (3,744 bytes) allocate the same at topN = 10^7 and at Integer.MAX_VALUE and return the 5 hits, as does IndexSearcher.search(q, Integer.MAX_VALUE, sort).

When it happens and when not

  • Needs: SortRescorer or Expression#getRescorer with a topN far above the number of first-pass hits. Any Sort, any index size, no setting.
  • Not affected: QueryRescorer, DoubleValuesSourceRescorer and IndexSearcher.search(q, n, sort) (which caps n at maxDoc, IndexSearcher.java:721, :729).
  • What stays right: in these topN >= hits.length calls, whenever the call succeeds, the hits, their order and their sort values are correct.
  • Not claimed: topN <= 0 (at topN = 0 SortRescorer throws "numHits must be > 0", like IndexSearcher.search(q, 0); a negative topN throws IllegalArgumentException in all three rescorers).
  • Same method, different bug: with topN below the hit count, returned hits get another hit's score (LFV-424).
Exposure and likelihood236 words
  • Who is hit. Code that re-sorts collected hits with SortRescorer or an expression and asks for all of them. That use is real: on java-user (2021-11-30, "How to change sorting after getting search results") Michael Sokolov pointed a user who collects all hits and re-sorts them to the Rescorers, and the user picked SortRescorer (credit: Michael Sokolov).
  • Default or opt-in. No setting is needed; any Sort, any index size. The API itself is opt-in and @lucene.experimental.
  • Effect. At Integer.MAX_VALUE: OutOfMemoryError on main (an Error, which code catching RuntimeException for bad arguments does not see, and which makes a JVM run with -XX:+ExitOnOutOfMemoryError exit), IllegalArgumentException naming an internal maxSize in 7.5.0 to 10.6.0, NegativeArraySizeException in 4.9.1 to 7.4.0. A topN that fits the array limit: OutOfMemoryError: Java heap space. Below that: no error, about 12 bytes per topN slot on every call. The heap OutOfMemoryError depends on the heap (runs used -Xmx1g).
  • Gradle detail. Lucene's test task runs with -XX:+HeapDumpOnOutOfMemoryError, so on main the repro also writes a heap dump of about 50 MB into lucene/core/build/tests-cwd/.
  • How likely: rare. The package docs describe topN below the hit count ("the best 1,000 hits by BM25 score may be sorted by descending popularity in order to compute the final top-100 hits"); the failure is loud at the extremes, so a developer meets it in testing; no matching public report was found.

Minimal repro

The test shows that SortRescorer.rescore with topN = Integer.MAX_VALUE ('re-sort all hits') fails on 2 first-pass hits, because it sizes its TopFieldCollector by topN instead of by the hit count.

TestSortRescorerMaxTopN.java (JUnit test)51 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 java.util.List;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSortRescorerMaxTopN extends LuceneTestCase {

  public void testTopNIntegerMaxValue() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        for (int i = 0; i < 2; i++) {
          w.addDocument(List.of(new NumericDocValuesField("price", i)));
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        TopDocs firstPass = searcher.search(new MatchAllDocsQuery(), 10);
        Rescorer rescorer = new SortRescorer(new Sort(new SortField("price", SortField.Type.LONG)));
        try {
          TopDocs rescored = rescorer.rescore(searcher, firstPass, Integer.MAX_VALUE);
          assertEquals(2, rescored.scoreDocs.length);
        } catch (OutOfMemoryError | IllegalArgumentException e) {
          // Expected: the 2 hits come back re-sorted (as with topN = 2); actual: rescore throws.
          throw new AssertionError("expected 2 re-sorted hits, but rescore threw " + e, e);
        }
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-997/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

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

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.search.TestSortRescorerMaxTopN
Expected vs actual
Expected: the 2 hits come back re-sorted (as with topN = 2). Actual (-ea and -da): OutOfMemoryError 'Requested array size exceeds VM limit' at SortRescorer.rescore:49 (IllegalArgumentException in 10.x/9.x releases).

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

More files, environment, full output56 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected 2 re-sorted hits, but rescore threw java.lang.OutOfMemoryError: Requested array size exceeds VM limit
       org.apache.lucene.search.TestSortRescorerMaxTopN.testTopNIntegerMaxValue(TestSortRescorerMaxTopN.java:46)
     Caused by: java.lang.OutOfMemoryError: Requested array size exceeds VM limit
       org.apache.lucene.search.comparators.LongComparator.<init>(LongComparator.java:38)
       org.apache.lucene.search.SortField.getComparator(SortField.java:503)
       org.apache.lucene.search.FieldValueHitQueue.create(FieldValueHitQueue.java:146)
       org.apache.lucene.search.TopFieldCollectorManager.newCollector(TopFieldCollectorManager.java:114)
       org.apache.lucene.search.SortRescorer.rescore(SortRescorer.java:49)
       [...]

Why it looks unintended

  • Rescorer#rescore documents only "@param topN How many re-scored hits to return"; QueryRescorer and DoubleValuesSourceRescorer return min(topN, hits) under that same contract.
  • Upstream TestSortRescorer and TestExpressionRescorer already call rescore(..., 10) on 3 hits.
  • IndexSearcher caps numHits at maxDoc for exactly this case (LUCENE-2119: "Don't throw ... if you pass Integer.MAX_VALUE as nDocs"), and TestIndexSearcher.testHugeN checks it.
The full argument284 words
  • Contract. Rescorer.java:40: "@param topN How many re-scored hits to return", no bound and no cost note. The pre-allocation NOTE sits on TopFieldCollectorManager (:44-45), an implementation detail the caller of SortRescorer never sees.
  • Siblings. QueryRescorer.java:120-123 and DoubleValuesSourceRescorer.java:79-82: if (topN < hits.length) select topN, otherwise return every hit. The same call returns the hits for two rescorers and throws for the third. ExpressionRescorer is documented as "Functionally ... the same as SortRescorer".
  • Tests. TestSortRescorer.java:96, :134 and TestExpressionRescorer.java:106 call rescore(searcher, hits, 10) on 3 hits; the assert at SortRescorer.java:86 (hits.length == rescoredDocs.scoreDocs.length) holds only when topN is at least the hit count.
  • Precedent. LUCENE-2119: "Don't throw NegativeArraySizeException if you pass Integer.MAX_VALUE as nDocs to IndexSearcher search methods"; LUCENE-4938: "Don't use an unnecessarily large priority queue in IndexSearcher methods that take top-N" (lucene/CHANGES.txt:18439-18441, :13901-13902). IndexSearcher caps at max(1, maxDoc) (IndexSearcher.java:721, :729) and TestIndexSearcher.testHugeN ("should not throw exception") calls search/searchAfter with Integer.MAX_VALUE, with and without a Sort. As Michael McCandless wrote on SOLR-5661: "Lucene's IndexSearcher tries to prevent this, when you ask for Integer.MAX_VALUE as the topN hits to the search method, it drops that to the maxDoc for the reader" (the precedent and its framing are theirs). testHugeN as precedent was first pointed out by an independent reviewer from another model family (OpenAI GPT-5.6), in its review of a sibling candidate of the same hunt (RescoreTopNQuery and the LatLonPoint nearest queues).
  • History. The line has passed topN since the class was written (LUCENE-5545, 2014); the 8 later commits that changed it are API migrations that keep topN (e.g. 38ca8d3e42c LUCENE-10002, bc18828fccc #13977), and the issue thread never discusses sizing.
The devil's-advocate check289 words

The intended-behaviour checker, the reviewers from another model family (OpenAI GPT-5.6) and the adjudication built the strongest case: the pre-allocation is documented, the caller chooses topN, Rescorer is @lucene.experimental, the documented use keeps topN below the hit count, and the LUCENE-2119 description (2009) even called Integer.MAX_VALUE "a nonsense value to pass in, since our PQ impl allocates the array up front". Direct users of TopFieldCollectorManager and TopScoreDocCollectorManager choose N and get an N-sized collector by design. So the answer could be "pass scoreDocs.length". The cross-model fact check rates this case moderate. On the related open #9588 (LUCENE-8542), Adrien Grand wrote that Lucene computes "the top-k matches of a query for some reasonable value of k" (credit them if this framing is used).

Why it fails: the NOTE is on the collector manager, not on SortRescorer or Rescorer; the same LUCENE-2119 text goes on "This trips users up fairly often because they assume our PQ doesn't statically pre-allocate (a reasonable assumption...)", and that issue was resolved by capping, not by rejecting the input (Michael McCandless, who on #9588 also called full-topN pre-allocation "really unexpected behavior"); the sibling rescorers in the same API cap; upstream tests use topN above the hit count; @lucene.experimental permits changing the API, which a cap does not need; and a topN that passes the queue's guard still fails on the heap, while a smaller one silently costs 120 MB for 5 hits. Sizing by N fits a collector that cannot know how many hits will arrive; SortRescorer already holds the finite hit list, so the exact bound, hits.length, is in hand. Verdict: not intended; expect the "pass scoreDocs.length" reply and answer it with the siblings and the IndexSearcher precedent.

Suggested fix

Recommended Cap the collector at the number of first-pass hits

Size the collector by the hits it can actually return: int numHits = Math.min(topN, Math.max(1, hits.length)); in SortRescorer.rescore, one new line and one changed line. It fixes Expression#getRescorer too. Validated on main: the repro passes, as do the rescorer tests of core and expressions, the org.apache.lucene.search test package and check.

// The collector pre-allocates numHits slots, and at most hits.length hits can come back:
int numHits = Math.min(topN, Math.max(1, hits.length));
TopFieldCollector collector =
    new TopFieldCollectorManager(sort, numHits, null, Integer.MAX_VALUE).newCollector();
All options and trade-offs3 options · 421 words

One fix was built and validated; two alternatives were not built. LFV-993's fix (the allocation order in FieldValueHitQueue on main) is separate: it would turn main's OutOfMemoryError back into the release IllegalArgumentException, but this call would still fail and still allocate by topN.

Cap the collector at the number of first-pass hits (validated by the checks) (recommended)

In SortRescorer.rescore, before the collector is built (1 file, +3/-1). The finder and the independent reproducer each wrote the same expression; a Z3 check of the hunt shows the size stays in [1, MAX_ARRAY_LENGTH) and that min(size, hits) == min(topN, hits) for every topN >= 1.

// The collector pre-allocates numHits slots, and at most hits.length hits can come back:
int numHits = Math.min(topN, Math.max(1, hits.length));
TopFieldCollector collector =
    new TopFieldCollectorManager(sort, numHits, null, Integer.MAX_VALUE).newCollector();

Trade-offs

  • Same results for every topN >= 1: the collector never holds more than hits.length entries anyway. max(1, ...) keeps an empty first pass legal (TopFieldCollectorManager rejects numHits <= 0); topN <= 0 is still rejected, as today.
  • Fixes Expression#getRescorer (inherited). Matches QueryRescorer, DoubleValuesSourceRescorer and IndexSearcher's cap.
  • Leaves LFV-424 (topN below the hit count) and LFV-993 (main's allocation order) as they are.

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle, JDK 25.0.3. Stock + the repro test: 1 test, 1 failed (OutOfMemoryError), assertions on and off.
  • Fix + the repro test: TestSortRescorerTopN, TestSortRescorer, TestQueryRescorer and TestDoubleValuesSourceRescorer 15 tests, TestExpressionRescorer 4 tests, 0 failed, with -Ptests.asserts=true and false.
  • Fix: the whole org.apache.lucene.search.* test package of lucene/core: 215 suites, 2,340 tests, 49 skipped, 0 failed; :lucene:core:check -x test (google-java-format, ecjLint, forbiddenApis): passes. The full Lucene suite was not run.
  • The finder's 3-test JUnit (SortRescorer at Integer.MAX_VALUE and at topN = 2,147,483,630, Expression#getRescorer at Integer.MAX_VALUE) with the fixed class: 3 of 3 pass. With the fix one call allocates 1,840 bytes at every topN for 5 hits.

Cap at maxDoc, like IndexSearcher

Math.min(topN, Math.max(1, searcher.getIndexReader().maxDoc())). Not built.

Trade-offs

Consistent with IndexSearcher.java:721, but still allocates in proportion to the index size for a handful of hits; hits.length is the exact bound and is already in hand.

Document the cost, or reject topN above the hit count

A javadoc NOTE on SortRescorer/Rescorer, or an IllegalArgumentException for topN > hits.length. Not built.

Trade-offs

Rejecting breaks the upstream tests that call rescore(..., 10) on 3 hits and makes SortRescorer stricter than its siblings; a NOTE alone leaves the Integer.MAX_VALUE idiom broken.

Downstream note

Solr's re-ranking passes topN equal to the hit count, so it does not reach this path (as Christine Poerschke noted in apache/solr#478); for Elasticsearch and OpenSearch the prior-art search ran keyword queries, but whether they call SortRescorer with a large topN was not analysed.

How it was found

A planning probe looking for unbounded size arguments ran SortRescorer and Expression#getRescorer with topN = Integer.MAX_VALUE on 5 hits and got OutOfMemoryError where the other rescorers returned the hits. The hunt confirmed it with a bounded differential harness and a JUnit, and checked the one-line cap with Z3.

The method in full188 words

Method: non-formal first signal, then a differential harness (the hunt's hunt unit for rescorers and other top-N queues pre-sized by an unbounded topN, 2026-10-28, main a9779ef3c193).

  • Lead. A planning probe of a numeric-limits round read the code and ran SortRescorer and Expression#getRescorer at topN = Integer.MAX_VALUE, 10^8 and 10^7 on 5 hits: OutOfMemoryError, a heap OutOfMemoryError after 762 MiB under a 1 GiB heap, and 114 MiB allocated, while the QueryRescorer and DoubleValuesSourceRescorer controls returned the 5 hits.
  • Harness. A bounded-exhaustive differential harness on the stock jar: 39 index layouts of 1 to 3 segments x 11 topN values, with oracles for the prefix property, the exact hit count, a reference sort model and an O(hits) memory bound. 234 SortRescorer/ExpressionRescorer failures (every layout at the two largest topN values, plus the memory oracle), 0 for the controls, 0 with the fix.
  • Z3. Bit-vector obligations show the cap Math.min(topN, Math.max(1, hits.length)) stays in [1, MAX_ARRAY_LENGTH) and keeps min(size, hits) == min(topN, hits); two mutants are caught.
  • JUnit. Reduced to a public-API JUnit; this doc then reduced it to the one-method test above.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-29, refreshed): 59 tool queries over GitHub, commits, downstream trackers, four mailing lists, the JIRA archive and the web, 17 own GitHub searches and the changed files of 201 PRs. Closest: open #9588, the same pre-allocation pattern in IndexSearcher's per-slice collectors.

Searches6 searches

Related, not reports of this bug: open #9588 (LUCENE-8542), the same pre-allocation class at a different fix site, with no mention of rescorers; #3195 (LUCENE-2119) and #6002 (LUCENE-4938), where IndexSearcher started capping numHits at maxDoc; SOLR-5661, which added the PriorityQueue size guard; #9999 (LUCENE-8956), QueryRescorer only (Adrien Grand suggested checking rescorers against topN = numDocs there); #6608 (LUCENE-5545), the origin, which never discusses sizing; apache/solr#478. Open PRs: only #16847 (draft, long doc ids) edits SortRescorer.java, and it keeps topN; #16857 edits TopFieldCollectorManager/FieldValueHitQueue for Sort pruning without a size cap. Tracking issue #16937 does not mention rescorers. Gaps: GitHub search returns at most 50 results per query; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs, commits and downstream trackers2026-10-29a scripted prior-report search (24 GitHub issue/PR queries on apache/lucene with class names, topN/numHits phrasings and the error texts, 3 commit, 2 downstream); 17 own searches incl. Solr, Elasticsearch, OpenSearchno report; the related items above
Open and recently closed PRs2026-10-29the changed files of 201 PRs (open PRs updated since 2026-09-29, closed PRs updated since 2026-10-14); rechecks of all 47 PRs updated since 2026-10-27 and of every PR updated since a recent cutoffonly #16847 edits SortRescorer.java (doc-id types only); none caps topN
Mailing lists2026-10-2920 tool queries over dev@, java-user@, issues@ and commits@, plus lists.apache.org queriesno report; the 2021-11-30 java-user thread shows the re-sort-all use case
JIRA archive2026-10-294 tool queries and JIRA REST queries, incl. the SOLR projectno report; LUCENE-2119, LUCENE-4938 and SOLR-5661 are precedents
Web2026-10-296 tool queries and 7 web searchesno report
Upstream fix status2026-10-29upstream main head and the newest commits on SortRescorer.javamain is 645d4fe14a20; the newest commit on the file is e5eb7e763ac (2025-06-30, a rename)

History

Introduced with the class in 4.8.0 (LUCENE-5545, cbd8cacc50c9, 2014); not a regression. All 16 executed versions from 4.8.0 to main fail with the controls passing. Only the exception at Integer.MAX_VALUE changed over time, through PriorityQueue changes elsewhere.

Versions tested6 rows

Introduced by cbd8cacc50c9 on trunk, "LUCENE-5545: add SortRescorer and Expression.getRescorer" (Michael McCandless, 2014-03-23, svn r1580490; CHANGES credits Simon Willnauer, Robert Muir, Mike McCandless; migrated as #6608, no PR), shipped through the branch_4x port 6970ada1746a (svn r1580491, the same day) in 4.8.0, the release that added the Rescorer API. The first version already called TopFieldCollector.create(sort, topN, ...); all 23 revisions of the file create the collector from topN, and the 8 later commits that changed the line are API migrations only (e.g. 38ca8d3e42c LUCENE-10002, bc18828fccc #13977).

The exception changed, the bug did not: SOLR-5661 (4.7.0) gave PriorityQueue its size guard (IllegalArgumentException in 4.8.0); LUCENE-5844 (4.9.1, 4.10.0) moved it after maxSize + 1, which wraps at Integer.MAX_VALUE (NegativeArraySizeException until 7.4.0); 154952f47d8 (7.5.0) restored the IllegalArgumentException; #14873 (main only) allocates the comparators first (OutOfMemoryError, LFV-993).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 and a9779ef3c193 affectedOutOfMemoryError "Requested array size exceeds VM limit" at Integer.MAX_VALUE; heap OOM below; 120 MB per call at topN = 10^7
7.5.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 4ae946e5a9b9 affectedIllegalArgumentException "maxSize must be >= 0 and < 2147483631"; heap OOM below; about 120 MB at 10^7
4.9.1, 4.10.0, 4.10.4, 5.5.5, 6.6.6, 7.4.0 affectedNegativeArraySizeException at Integer.MAX_VALUE; heap OOM below
4.8.0 affectedIllegalArgumentException "maxSize must be <= 2147483391"; heap OOM below; 120 MB at 10^7
4.7.2 API absentno Rescorer API (compile error)
main + fix not affectedthe 5 hits at every topN, 1,840 bytes per call; rescorer suites and the search test package pass

Independent checks

An independent reproducer, an intended-behaviour devil's advocate and reading-only reviews by another model family (OpenAI GPT-5.6) agree on the root cause; the adjudication re-ran the independent test and confirmed it with high confidence. This doc re-ran a one-method repro on 645d4fe14a20 and validated the fix with gradle.

Checks and verdicts383 words

The cross-model reviews were reading-only: they could not run Java. The refute-lens review returned a procedural "not real, low" while agreeing on the root cause; a separate re-run failed 3 of 3. An executing cross-model review is still open.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateJUnit 3 of 3 fail on main (-ea, -da), the 10.6 RC, 10.5.2 and 9.12.3; harness 234 failures for the two classes, 0 for the controls, 0 with the fix.
cross-model review, intended lens real, mediumOpenAI GPT-5.6, reading-only; judged the intended-behaviour case weak and pointed to IndexSearcher's maxDoc cap and LUCENE-2119 (the testHugeN pointer came from the same reviewer's review of a sibling candidate). A separate run re-ran the repro: 3 of 3 fail.
Independent reproducer realOwn public-API JUnit (4 docs in 2 segments) fails 3 of 5 on main, 10.5.2 and 9.12.3, controls pass; 120,003,312 bytes at topN = 10^7; own one-line fix passes 5 of 5.
Cross-model review, refute lens not real, low (procedural)OpenAI GPT-5.6, reading-only, could not run anything; its notes call the verdict "a procedural default rather than a substantive refutation"; root cause agreed, intended case weak.
Intended-behaviour devil's advocate real, low impactArgued the documented pre-allocation, @lucene.experimental, caller choice and the LUCENE-2119 remark, and rejected each; re-ran the repro: 3 of 3 fail.
adjudication confirmed, highRe-ran the independent reproduction's test with a new seed on main, -ea and -da: 3 of 5 fail each, same frames. Class wrong error.
credibility audit keepRe-ran the repro on main: 3 of 3 fail; not a merge with LFV-424 or LFV-993.
this doc confirmed on 645d4fe14a20The one-method repro fails through gradle and the standalone runner, assertions on and off, and on 10.5.2; the fix validated.
fact check substantively correct; 4 fixes appliedRe-ran the repro through gradle (the doc's command and a new seed) and the standalone runner, -ea and -da, on main and on 10.5.2, and the fix suites; 4 wording and citation errors, fixed.
cross-model fact check 5 factual errors, fixed; intended case moderateOpenAI GPT-5.6, reading only: core mechanism, runs and fix verified; 5 factual errors (fixed), 5 overclaims and 4 missing caveats (addressed).

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

Finding record

LFV-997, snapshot of 2026-11-06

Registry title
SortRescorer and Expression#getRescorer size their sort collector by topN instead of by the number of first-pass hits, so a topN far above the hit count (such as Integer.MAX_VALUE for 're-sort them all') throws (OutOfMemoryError on main, IllegalArgumentException in current releases) or allocates memory in proportion to topN, while QueryRescorer and DoubleValuesSourceRescorer return the hits
Area
search / rescoring (SortRescorer; expressions ExpressionRescorer inherits rescore())
Priority
Low, rank 391: The core SortRescorer and Expression#getRescorer fail loudly on the topN = Integer.MAX_VALUE 're-sort all' idiom and use memory in proportion to topN, but results are always correct when the call succeeds, which is the same core MAX_VALUE pre-size profile as the kNN row LFV-454 with a less common API.
Impact class
wrong error
Impact note
Applications that call SortRescorer.rescore, or the Rescorer returned by Expression#getRescorer, with a topN far above the first-pass hit count (typically Integer.MAX_VALUE to mean 're-sort all hits') get an OutOfMemoryError 'Requested array size exceeds VM limit' on main, an IllegalArgumentException naming an internal maxSize in 4.8.0 and 7.5.0-10.6.0 (NegativeArraySizeException in 4.9.1-7.4.0), a heap OutOfMemoryError at very large legal topN, and below that a per-call allocation of about 12 bytes per topN slot for a single long sort whatever the hit count (about 120 MB for 4 hits at topN = 10^7); default settings, any index size; the failure is loud and results are correct whenever the call succeeds.
Summary
You run a first-pass search that returns 5 hits and re-sort them with new SortRescorer(sort).rescore(searcher, firstPass, Integer.MAX_VALUE), meaning 're-sort all of them'; you expect the 5 hits back in sort order, as topN = 5 or 10 give and as QueryRescorer and DoubleValuesSourceRescorer give for any topN. Instead the call throws OutOfMemoryError 'Requested array size exceeds VM limit' on main (IllegalArgumentException 'maxSize must be >= 0 and < 2147483631' in 10.x and 9.x), because SortRescorer sizes its TopFieldCollector, which pre-allocates its arrays, by topN instead of by the number of hits it was given. Smaller large values succeed but cost memory in proportion to topN (about 120 MB for 4 hits at topN = 10^7), and Expression#getRescorer inherits the same code.
Introduced
cbd8cacc50c9 (LUCENE-5545 'add SortRescorer and Expression.getRescorer', migrated as GitHub issue #6608; Michael McCandless; svn r1580490 on trunk, shipped through the branch_4x port 6970ada1746a, r1580491, same day; Subversion-era patch, no PR) [read: git log --follow over all 23 revisions of SortRescorer.java and all 11 of ExpressionRescorer.java; every revision creates the collector from topN; later changes to the line are API migrations only (LUCENE-8059, LUCENE-8405/8411/8412/8430/8431, LUCENE-10002 38ca8d3e42c, #13977 bc18828fccc); exec: 4.7.2 lacks the API, 4.8.0 fails] (2014-03-23)
First affected version
4.8.0 [exec] (the release that introduced the Rescorer API; 4.7.2 does not have it: the portable check does not compile there [exec])
Affected versions
every release from 4.8.0 through 10.5.2, 10.6.0 (released 2026-10-29; branch_10_6 head 4ae946e5a9b9) and main a9779ef3c193; executed on 4.8.0, 4.9.1, 4.10.0, 4.10.4, 5.5.5, 6.6.6, 7.4.0, 7.5.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 and main (all fail) [exec], the releases in between by reading (every revision of SortRescorer.java passes topN unchanged) [read]. Only the error at topN = Integer.MAX_VALUE changed over time: IllegalArgumentException 'maxSize must be <= 2147483391' in 4.8.0, NegativeArraySizeException in 4.9.1-7.4.0, IllegalArgumentException 'maxSize must be >= 0 and < 2147483631' in 7.5.0-10.5.2 and 10.6.0 (branch_10_6), OutOfMemoryError 'Requested array size exceeds VM limit' on main only (#14873, unreleased); a heap OutOfMemoryError at MAX_ARRAY_LENGTH - 1 (-Xmx1g) and about 120 MB per call at topN = 10^7 in every executed version [exec]. Not fixed upstream: SortRescorer.java:48-49 unchanged on upstream main (checked 2026-10-29, newest commit on the file e5eb7e763ac, author date 2025-06-30) and on branch_11x, branch_10x and branch_10_6 [read]
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
non-formal: code reading; also reached by exhaustive check
Upstream and prior reports
none found (the prior-report record verdict new, searched 2026-10-29, read-only; recheck 2026-10-29): a scripted prior-report search of 59 queries (24 GitHub issue/PR on apache/lucene, 3 commit, 2 downstream, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA, 6 web; 145 hits, 0 high) plus retries, 14 manual GitHub searches (SortRescorer, ExpressionRescorer, getRescorer, TopFieldCollectorManager/FieldValueHitQueue with Integer.MAX_VALUE or OutOfMemoryError, recent rescore items, apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch), the changed files of 201 open and recently closed apache/lucene PRs (only #16847, long doc ids, edits SortRescorer.java, and keeps topN), JIRA REST and lists.apache.org queries, 7 web searches; the recheck repeated six GitHub searches and scanned the files of all 47 PRs updated since 2026-10-27 (none touches SortRescorer, ExpressionRescorer or Rescorer; #16857 edits TopFieldCollectorManager/FieldValueHitQueue for Sort pruning without a numHits cap), and tracking issue #16937 does not mention rescorers. Related, not the same bug: open #9588 (LUCENE-8542, the same pre-allocation class at IndexSearcher's per-slice collectors); precedents #3195 (LUCENE-2119) and #6002 (LUCENE-4938), where IndexSearcher caps numHits at maxDoc, and SOLR-5661 (PriorityQueue maxSize guard); #9999 (LUCENE-8956, QueryRescorer only); #6608 (LUCENE-5545, origin, never discusses sizing); apache/solr#478 notes that Solr's re-ranking passes topN equal to the hit count, so Solr does not reach this path
Lucene commit tested
main 645d4fe14a20 (runs of 2026-10-29) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 release jars. Earlier hunt runs: 16 versions from 4.8.0 to main (14 releases, branch_10_6 4ae946e5a9b9 and main a9779ef3c193, where the files involved are identical to 645d4fe14a20). The fix was validated in the scratch worktree: the repro, the rescorer suites of core and expressions, the org.apache.lucene.search.* test package and :lucene:core:check -x test.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/SortRescorer.java:42 (hits = firstPassTopDocs.scoreDocs.clone()), :48-49 (new TopFieldCollectorManager(sort, topN, null, Integer.MAX_VALUE).newCollector()); Rescorer.java:30 (@lucene.experimental), :40 ('@param topN How many re-scored hits to return'); TopFieldCollectorManager.java:44-45 (NOTE: pre-allocate a full array of length numHits); QueryRescorer.java:119-122 and DoubleValuesSourceRescorer.java:79-82 (select min(topN, hits.length)); IndexSearcher.java:721-733 (caps numHits at max(1, maxDoc)); lucene/expressions/.../ExpressionRescorer.java:30-37 ('Functionally this is the same as SortRescorer'), Expression.java:112-114 (getRescorer)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/SortRescorer.java:27, (at 645d4fe14a20) :42-49, :60-86
  • lucene/core/src/java/org/apache/lucene/search/Rescorer.java:30, (at 645d4fe14a20) :40; TopFieldCollectorManager.java:44-45, :62-65, :112-114; FieldValueHitQueue.java:139-153; comparators/LongComparator.java:38; lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:121-134; IndexSearcher.java:721, :729
  • lucene/expressions/src/java/org/apache/lucene/expressions/ExpressionRescorer.java:30-37; (at 645d4fe14a20) Expression.java:112-114
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/package-info.java:371-373; (at 645d4fe14a20) Rescorer.java:30
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/Rescorer.java:40; (at 645d4fe14a20) TopFieldCollectorManager.java:44-45; QueryRescorer.java:120-123; DoubleValuesSourceRescorer.java:79-82; SortRescorer.java:86; IndexSearcher.java:721, :729
  • lucene/core/src/test/org/apache/lucene/search/TestSortRescorer.java:96, (at 645d4fe14a20) :134; TestIndexSearcher.java:78-79; lucene/expressions/src/test/org/apache/lucene/expressions/TestExpressionRescorer.java:106
  • lucene/CHANGES.txt:12467-12469 (at 645d4fe14a20) (LUCENE-5545), :13901-13902 (LUCENE-4938), :18439-18441 (LUCENE-2119)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/TopFieldCollectorManager.java:62-65; (at 645d4fe14a20) IndexSearcher.java:721

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

Repro files

About the diagrams

It is made in parallel with this doc; every on-screen value comes from the code at 645d4fe14a20 or the diagram's executed probe.

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.