All findings · Lucene finding · LFV-1005 · passageselector-queue-presized-by-max-passages

matchhighlight PassageSelector sizes its passage queue by maxPassages before it looks at any match, so maxPassages = Integer.MAX_VALUE throws

Ask matchhighlight's PassageSelector.pickBest for maxPassages = Integer.MAX_VALUE (meant as "all passages") and it throws IllegalArgumentException, even for a text with one match. It sizes its passage queue by maxPassages before it reads the matches, so large values that work still cost memory: 64 MiB per call at 2^24.

Summary

Priority
Low, rank 646This is the matchhighlight version of the UnifiedHighlighter maxPassages = Integer.MAX_VALUE row LFV-438; it fails loudly with a clear IllegalArgumentException in a little-used package, so it closes the MAX_VALUE pre-size group after LFV-995.
Impact
wrong error
Since
9.0.0 (2021); the sizing came with the class (2020)introduced 2020-08-14 · about 6 years
Affected
every release from 9.0.0 to 10.6.0, and main
Status
documented
Found by
a numeric-limits probe of public highlighter arguments, then a hand proof and translation validation

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

pickBest reserves a queue slot for every passage you might ask for before it looks at a single match. A text with one match can only ever fill one slot, yet Integer.MAX_VALUE asks for 2,147,483,647 slots, and Lucene's PriorityQueue refuses any size from 2,147,483,631 up.

Background: matchhighlight passages

  1. MatchHighlighter (package org.apache.lucene.search.matchhighlight) highlights query matches in stored field values. For each field with matches, FieldValueHighlighters.highlighted(maxPassageWindow, maxPassages, ...) asks a PassageSelector for the best passages.
  2. PassageSelector.pickBest(value, markers, maxPassageWindow, maxPassages) centres a window of at most maxPassageWindow characters on each match (a marker), keeps the best candidates in a PriorityQueue, removes overlaps and returns at most maxPassages passages.
  3. Lucene's PriorityQueue has a fixed capacity chosen when it is created: it allocates capacity + 1 slots up front and throws for a capacity of 2,147,483,631 or more.
Summary of the bug. The user calls PassageSelector.pickBest on "lucene search" with one match ("lucene"), window 20 and maxPassages = Integer.MAX_VALUE; expected: 1 passage, 0-13, as maxPassages = 1, 10, 16 or 1000 return. Its queue asks for maxSize = 2,147,483,647 passages, of which 1 would be used, and the call throws IllegalArgumentException: maxSize must be >= 0 and < 2147483631, with assertions on and off and through MatchHighlighter. Why: PassageSelector.java:93 int pqSize = Math.max(16, maxPassages); :96 creates the queue; only :98 splits the markers; PriorityQueue rejects maxSize >= 2,147,483,631 and below that pre-allocates maxSize + 1 slots. Elsewhere maxPassages only caps the result. Below the limit, maxPassages = 2^24 costs 64 MiB for 1 passage.
The one-match call with maxPassages = Integer.MAX_VALUE, the queue that pickBest sizes before it reads the match, PriorityQueue's size limit, the memory cost below that limit, and the fix that sizes the queue by the matches. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Highlight the text lucene search, which has one match (lucene, offsets 0-6): pickBest("lucene search", List.of(new OffsetRange(0, 6)), 20, Integer.MAX_VALUE), with a 20-character window and Integer.MAX_VALUE meant as "all passages".
  2. You expect the one passage 0-13 around the match: maxPassages = 1, 10, 16 and 1000 all return exactly that.
  3. You get IllegalArgumentException: maxSize must be >= 0 and < 2147483631; got: 2147483647. MatchHighlighter with FieldValueHighlighters.highlighted(20, Integer.MAX_VALUE, ...) fails the same way on a field with a match.
  4. Why: before it reads a match, pickBest creates its PriorityQueue with Math.max(16, maxPassages) slots. That queue allocates every slot up front and refuses a size of 2,147,483,631 or more, yet it never receives more than one passage per match.
  5. Large values below that limit return the right passage but allocate for maxPassages, not for the matches: 552 bytes at 10, 4 MiB at 2^20, 64 MiB at 2^24; at 3*10^8 a 1 GiB heap runs out.

Who is affected

Applications that highlight with matchhighlight (MatchHighlighter with FieldValueHighlighters.highlighted, or PassageSelector directly) and pass Integer.MAX_VALUE as "all passages", or forward a large count without a bound. Default settings; ordinary counts work. The error is loud and results are never wrong; large accepted values cost memory on every call.

Mechanism, terms and numbers538 words

PassageSelector.pickBest(value, markers, maxPassageWindow, maxPassages) picks the best highlight passages of a field value; MatchHighlighter reaches it through FieldValueHighlighters.highlighted, which passes maxPassages on unchanged. Before it reads a single match, pickBest builds its PriorityQueue with Math.max(16, maxPassages) slots, and PriorityQueue allocates maxSize + 1 slots up front and rejects any maxSize of 2,147,483,631 or more. So maxPassages = Integer.MAX_VALUE (meant as "all passages") throws IllegalArgumentException on every call with a non-empty value and a non-zero window, even for one match, where maxPassages = 10 returns the one passage. Everywhere else in the method maxPassages only caps the result, and the queue never receives more passages than there are (split) matches, so the extra capacity never changes the result. Large values below the limit work but allocate in proportion to maxPassages: 64 MiB per call at 2^24 for one match. The sizing came with the class in 2020, so every release that ships matchhighlight (9.0.0 to 10.6.0) and main is affected. Default settings; the failure is loud and results are never wrong.

The code (main 645d4fe14a20, lucene/highlighter/.../matchhighlight/)

  • Entry points. PassageSelector.pickBest(value, markers, maxPassageWindow, maxPassages) (PassageSelector.java:67-74) delegates to the five-argument overload (:76-84). Neither has javadoc. FieldValueHighlighters.highlighted(maxPassageWindow, maxPassages, formatter, matchFields) (FieldValueHighlighters.java:88-115) does not document maxPassages and passes it unchanged to pickBest (:109-110). No argument is validated.
  • The pre-size. After the early return for an empty value or a zero window (:87-89): int pqSize = Math.max(16, maxPassages); (:93), then PriorityQueue<Passage> pq = PriorityQueue.usingComparator(pqSize, passageScorer); (:96), and only then markers = splitOrTruncateToWindows(...) (:98).
  • PriorityQueue. The class javadoc says it "pre-allocates an array of length {@code maxSize+1}" (PriorityQueue.java:34-35; the class is @lucene.internal). The constructor throws IllegalArgumentException("maxSize must be >= 0 and < " + ArrayUtil.MAX_ARRAY_LENGTH + ...) for maxSize >= ArrayUtil.MAX_ARRAY_LENGTH ("Throw exception to prevent confusing OOME", :121-125), otherwise allocates new Object[maxSize + 1] (:129-134). MAX_ARRAY_LENGTH is Integer.MAX_VALUE - NUM_BYTES_ARRAY_HEADER, 2,147,483,631 on the tested JVM.
  • maxPassages is otherwise only a cap. The selection loop makes at most one insertWithOverflow per split marker (:103-166, insert at :158); the result is clamped by last = Math.min(last, maxPassages); (:213); with no candidate, pickDefaultPassage stops at defaultPassages.size() >= maxPassages (:289). So once the queue can hold every split marker, more capacity cannot change the result.

The numbers (main, -da, 1 GiB heap, bytes allocated by the calling thread during one call after a warm-up)

pickBest("lucene search", ..., 20, maxPassages)one match [0, 6)no match
maxPassages = 101 passage, 552 bytes1 default passage, 544 bytes
2^20 = 1,048,5761 passage, 4 MiB1 passage, 4 MiB
2^24 = 16,777,2161 passage, 64 MiB1 passage, 64 MiB
2^31 - 18 = 2,147,483,630OutOfMemoryError: Java heap spacesame
2,147,483,631 and up, incl. Integer.MAX_VALUEIllegalArgumentExceptionsame
any of these, with the fix1 passage, 552 bytes1 passage, 544 bytes

The no-match column matters only for direct pickBest callers: highlighted formats only fields that have matches (FieldValueHighlighters.java:97-98). End to end, MatchHighlighter with highlighted(20, 10, ...) returns [>lucene< search], and with Integer.MAX_VALUE the same IllegalArgumentException escapes MatchHighlighter.highlight (FieldValueHighlighters.java:109, MatchHighlighter.java:266).

Exposure and likelihood215 words
  • What goes wrong. Integer.MAX_VALUE (anything from 2,147,483,631 up) fails every pickBest call on a non-empty value with a non-zero window, even when nothing matches, and so every MatchHighlighter call on a document with a match in a highlighted field (a field without a match never reaches pickBest). The message names the internal queue size, not the argument the caller passed.
  • Large accepted values. They return the right passages but allocate about 4 bytes per unit of maxPassages on every call (64 MiB at 2^24); with a 1 GiB heap, 3*10^8, 10^9 and 2^31 - 18 end in OutOfMemoryError (3*10^8 and 10^9 pass with 4 GiB; 2^31 - 18 also runs out with 2 GiB). Concurrent calls multiply the allocation.
  • Scale. None needed: one 13-character value with one match. Fixed input, assertions on or off.
  • Workaround. Pass a finite count. Once maxPassages is at least the number of matches (counted after splitting at value boundaries) and the number of field values, a larger value changes nothing.
  • How likely: rare. A code search found no use of matchhighlight's PassageSelector in Solr or Elasticsearch; Integer.MAX_VALUE fails on the first call, so a developer meets it in testing and picks a finite count; no public report was found in the documented searches since the class shipped in 9.0.0.

Minimal repro

One JUnit method on the public API: pickBest on lucene search with its one match, a 20-character window and maxPassages = Integer.MAX_VALUE. Fixed input, one thread, no index; it fails with assertions on and off and with every seed tried. With the fix it passes unchanged, so it doubles as the regression test.

TestPassageSelectorMaxValueMaxPassages.java (lucene/highlighter, package org.apache.lucene.search.matchhighlight, 1 test)31 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.matchhighlight;

import java.util.List;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestPassageSelectorMaxValueMaxPassages extends LuceneTestCase {

  public void testMaxValueMaxPassages() {
    // expected: the single passage around the one match; actual: IllegalArgumentException
    List<Passage> passages =
        new PassageSelector()
            .pickBest("lucene search", List.of(new OffsetRange(0, 6)), 20, Integer.MAX_VALUE);
    assertEquals(1, passages.size());
  }
}
Command
cp $REPRO/TestPassageSelectorMaxValueMaxPassages.java $LUCENE/lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/
cd $LUCENE && ./gradlew :lucene:highlighter:test --tests org.apache.lucene.search.matchhighlight.TestPassageSelectorMaxValueMaxPassages -Ptests.seed=DEADBEEF
Expected
pickBest returns the 1 passage around the match (offsets 0-13), as it does for maxPassages = 10.
Observed
IllegalArgumentException: maxSize must be >= 0 and < 2147483631; got: 2147483647 from PriorityQueue.<init> via PassageSelector.pickBest(PassageSelector.java:96).

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

More files, environment, full output260 words

Copy the test into lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/ of a Lucene checkout and run it with gradle (command below). It is in the matchhighlight package like the upstream tests and uses only public API.

Executed: gradle on a clean 645d4fe14a20 (fails) and with fix A (passes); the standalone runner on the stock main jars with -ea and -da (fails; passes with the fix), on the 10.6.0 jars (-ea) and on 10.5.2 and 9.12.3 (-da): all fail with the same IllegalArgumentException. Seeds tried: DEADBEEF, 5EED1234. The exception does not depend on the heap size: the PriorityQueue guard runs before any allocation. On 10.6.0 and older the frame below PriorityQueue.<init> is PassageSelector$1.<init>(PassageSelector.java:97), because those releases build an anonymous PriorityQueue subclass (main uses PriorityQueue.usingComparator since #14705).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on 10.6.0, 10.5.2 and 9.12.3
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (plain int arithmetic, nothing platform-specific)
Command (environment)
cp $REPRO/TestPassageSelectorMaxValueMaxPassages.java $LUCENE/lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/
cd $LUCENE && ./gradlew :lucene:highlighter:test --tests org.apache.lucene.search.matchhighlight.TestPassageSelectorMaxValueMaxPassages -Ptests.seed=DEADBEEF

Observed output

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF (module prefixes trimmed)
> Task :lucene:highlighter:test FAILED

TestPassageSelectorMaxValueMaxPassages > testMaxValueMaxPassages FAILED
    java.lang.IllegalArgumentException: maxSize must be >= 0 and < 2147483631; got: 2147483647
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:C5AAB44B573F209A]:0)
        at org.apache.lucene.util.PriorityQueue.<init>(PriorityQueue.java:123)
        at org.apache.lucene.util.PriorityQueue.<init>(PriorityQueue.java:80)
        at org.apache.lucene.util.PriorityQueue.usingComparator(PriorityQueue.java:63)
        at org.apache.lucene.search.matchhighlight.PassageSelector.pickBest(PassageSelector.java:96)
        at org.apache.lucene.search.matchhighlight.PassageSelector.pickBest(PassageSelector.java:72)
        at org.apache.lucene.search.matchhighlight.TestPassageSelectorMaxValueMaxPassages.testMaxValueMaxPassages(TestPassageSelectorMaxValueMaxPassages.java:28)

:lucene:highlighter:test (FAILURE): 1 test, 1  failure
BUILD FAILED in 44s

# standalone runner, seed DEADBEEF
main 645d4fe14a20  -ea / -da   tests_run=1 failures=1   same IllegalArgumentException, PassageSelector.java:96
10.6.0             -ea         tests_run=1 failures=1   same, via PassageSelector$1.<init>(PassageSelector.java:97)
10.5.2, 9.12.3     -da         tests_run=1 failures=1   same, via PassageSelector$1.<init>(PassageSelector.java:97)
main + fix A       -ea / -da   tests_run=1 failures=0

Why it looks unintended

  • Elsewhere maxPassages only caps the result, and the queue never holds more than one passage per match.
  • The same method is meant to accept Integer.MAX_VALUE as its window size; the package documents it as "all" for maxHitsPerField.
  • The sizing was meant to keep queue overhead low (#11990); 64 MiB for one match defeats that, and the fix keeps it.
The full argument338 words
  • Cap semantics. last = Math.min(last, maxPassages) (PassageSelector.java:213) and defaultPassages.size() >= maxPassages (:289) work at Integer.MAX_VALUE; the queue cannot receive more entries than there are split markers (:103-166).
  • The other "max" argument, meant to accept Integer.MAX_VALUE. LUCENE-10135 (#334, Dawid Weiss, 2021, in 9.0.0) fixed an overflow for a huge maxPassageWindow, and the upstream test TestPassageSelector.largeWindow passes Integer.MAX_VALUE as the window (TestPassageSelector.java:159-162). A residual window-shift overflow at that window, for permitted ranges after offset 0, is a separate, already known bug.
  • Package convention. MatchRegionRetriever's maxHitsPerField: "The predicate should return {@link Integer#MAX_VALUE} for all hits to be considered" (MatchRegionRetriever.java:230-233).
  • The module's other passage API. The UnifiedHighlighter documents maxPassages as "The maximum number of top-N ranked passages" (UnifiedHighlighter.java:757) and bounds its queue with Math.min(64, maxPassages + 1) (FieldHighlighter.java:143; its own wrap at Integer.MAX_VALUE is a separate bug).
  • Tests. Upstream TestPassageSelector uses maxPassages from 1 to 100 (100 in checkEmptyExtra, checkOddOverlaps and defaultPassages, TestPassageSelector.java:40, :48, :57, :69, :195; randomIntBetween(1, 10) in its random test, :322); nothing says large values are unsupported.
  • History. The class was created in 2020 with new PriorityQueue<>(maxPassages), when the queue capacity was the only cap on the result. #11990 (Alan Woodward, 2022, 9.5.0) added the explicit clamp at :213. In that review Dawid Weiss wrote that capping the queue at the requested maximum was "a deliberate decision ... so that regardless of how many markers there are, the overhead of the pq remains fairly low" and suggested min(markers.size(), maxPassages * 3); Alan Woodward proposed the floor of 16 instead, and it was merged. Large maxPassages was never discussed.
  • Precedent. LUCENE-2119 (2009), opened by Michael McCandless about IndexSearcher.search(q, Integer.MAX_VALUE): "This trips users up fairly often because they assume our PQ doesn't statically pre-allocate (a reasonable assumption...)". The fix capped the count at maxDoc in the layer that knew the real bound; here pickBest knows it, the number of split markers.
The devil's-advocate check200 words

The strongest case for "works as designed" (a reviewer from another model family and an intended-behaviour devil's advocate): sizing by maxPassages was a deliberate #11990 choice to keep queue overhead low; PriorityQueue documents its pre-allocation, and SOLR-5661 added its size guard on purpose so that impossible sizes fail loudly; core top-N collectors (new TopScoreDocCollectorManager(Integer.MAX_VALUE, ...)) and the classic Highlighter.getBestFragments(..., Integer.MAX_VALUE) fail the same way; and nothing documents maxPassages at all.

Why it fails: the stated goal was low overhead, and the fix is never larger than today's queue; the pre-allocation note sits on a @lucene.internal class that a pickBest caller is not expected to read; for the core collectors, IndexSearcher itself caps the count at Math.max(1, maxDoc) before it builds the collector manager (IndexSearcher.java:588-599; search(q, Integer.MAX_VALUE) returns 1 hit where a direct TopScoreDocCollectorManager(Integer.MAX_VALUE, ...) throws), the layer that knows the bound applies it, and here that layer is pickBest, which already holds every marker; and the same method and package treat Integer.MAX_VALUE as a legal "no limit" value. The argument bears on priority (a Low robustness fix), not on whether the call should work. A maintainer may still prefer a javadoc note (see Suggested fix).

Suggested fix

Recommended A. Split the markers first, then size the queue by the split count

Split the markers first, then size the queue by them: Math.max(16, Math.min(maxPassages, markers.size())) (+5/-4 lines in PassageSelector). The queue is never larger than today's, and results are the same for every value that works today. With it, the minimal test and all 723 highlighter tests pass.

markers = splitOrTruncateToWindows(markers, maxPassageWindow, permittedPassageRanges);

// minimum priority queue size of 16 so that small maxPassages values don't
// return too few passages due to subsequent passage merges. At most one passage
// is collected per (split) marker, so the queue never needs to be larger than that.
int pqSize = Math.max(16, Math.min(maxPassages, markers.size()));

// Best passages so far.
PriorityQueue<Passage> pq = PriorityQueue.usingComparator(pqSize, passageScorer);
All options and trade-offs3 options · 599 words

Three options. Option A was validated with gradle in a scratch worktree at 645d4fe14a20 and by translation validation against the stock class; B and C were not executed. Credit: the shape of A (bound the queue by the marker count) is what Dawid Weiss suggested in the #11990 review; the floor of 16 it keeps is Alan Woodward's.

A. Split the markers first, then size the queue by the split count (validated by the checks) (recommended)

In PassageSelector.pickBest, move markers = splitOrTruncateToWindows(...) (:98) above the queue and compute pqSize = Math.max(16, Math.min(maxPassages, markers.size())) instead of Math.max(16, maxPassages) (:93).

markers = splitOrTruncateToWindows(markers, maxPassageWindow, permittedPassageRanges);

// minimum priority queue size of 16 so that small maxPassages values don't
// return too few passages due to subsequent passage merges. At most one passage
// is collected per (split) marker, so the queue never needs to be larger than that.
int pqSize = Math.max(16, Math.min(maxPassages, markers.size()));

// Best passages so far.
PriorityQueue<Passage> pq = PriorityQueue.usingComparator(pqSize, passageScorer);

Trade-offs

  • No change in results for any maxPassages the current code accepts: with capacity at least the number of split markers nothing is ever evicted, and below that the size is unchanged (for valid inputs; by a hand proof, not published).
  • The queue is never larger than today's (unchanged when maxPassages <= markers.size() or when both sizes are the floor of 16, smaller otherwise), so it keeps the low-overhead goal of #11990. The split does the same work, only earlier. No API change.
  • Use the count after the split: a match that crosses value boundaries becomes several markers, and a variant using the caller's markers.size() loses passages.
  • Headroom like the maxPassages * 3 from the #11990 review must avoid int overflow: it wraps negative for maxPassages > 715,827,882.

What the checks validated

  • Gradle, scratch worktree at 645d4fe14a20, JDK 25, -Ptests.seed=DEADBEEF: without the fix the minimal test fails; with it, :lucene:highlighter:checkGoogleJavaFormat :lucene:highlighter:test is BUILD SUCCESSFUL: 35 suites, 723 tests, 0 failures, 0 errors, 0 skipped, among them TestPassageSelector 20/20, TestMatchHighlighter 6/6, TestMatchRegionRetriever 22/22 and the minimal test 1/1 (the fixed main source was recompiled; the test classes, which the fix does not touch, were up to date from the unfixed run).
  • The standalone runner with the fixed class shadowed on the stock main jars: the minimal test passes with -ea and -da; the allocation probe returns the same passage with 552 bytes allocated for every maxPassages up to Integer.MAX_VALUE.
  • Translation validation of the stock class against this variant on the stock jar: 31,086,300 checks with assertions off and 24,980,382 on, 0 failures on the sampled maxPassages values (0 to 20, around the bound beyond which the result cannot change, 4096, three random values below it, and 2^30, Integer.MAX_VALUE - 1 and Integer.MAX_VALUE); that the results are equal for every value rests on the hand proof (not published), not on the run; all three mutants of the fix (pre-split count, no floor, off by one) were killed.

B. Cap maxPassages in FieldValueHighlighters.highlighted only

Clamp the value in the convenience factory before it calls pickBest.

Trade-offs

Fixes the MatchHighlighter path but not direct pickBest callers, and the factory does not know the split marker count. Not recommended; not executed.

C. Document a limit

A javadoc note on both pickBest overloads and on highlighted that maxPassages, a positive count, is allocated up front and must stay below 2^31 - 17.

Trade-offs

No behaviour change; turns the "all passages" idiom into a documented error and keeps the memory cost of large values. A fallback if maintainers reject A; not executed.

Downstream note

An application that forwards a user-chosen passage count to MatchHighlighter or PassageSelector has to cap it itself, as for any top-N size. A code search found no use of PassageSelector in Solr or Elasticsearch.

How it was found

A probe of numeric limits on public highlighter arguments saw pickBest throw at Integer.MAX_VALUE - 1 where 1000 returns two passages. A hand proof showed that the queue size cannot change the result once it holds every match, 31 million generated checks on the stock jar confirmed it, and the case was reduced to one public-API test.

The method in full158 words

Method: executed differential testing with a hand proof (no formal model), in a hunt round on numeric limits of public API arguments, on the stock main jars.

  • First signal. A planning probe called pickBest(value, markers, 20, Integer.MAX_VALUE - 1) on a text with two matches: IllegalArgumentException, where n = 1000 returns 2 passages; n = 10^8 allocated 381 MiB (main a9779ef3c193, 2026-10-28).
  • Proof and check. The finder wrote down why the result cannot depend on the queue capacity once it covers the split markers (at most one insert per marker, the clamp, the default path), then checked it on the stock jar by bounded-exhaustive plus seeded-random translation validation against the fixed variant and three mutants of it: 31,086,300 checks with assertions off, 24,980,382 on, 0 failures, all mutants killed. The Integer.MAX_VALUE failure appeared on all 693,640 inputs with a non-empty value and a non-zero window.
  • Reduction. A public-API JUnit test, later the single test shown here.

Prior reports, history and checks

Prior reports

No earlier report found (searched 2026-10-29, re-checked twice on 2026-10-30) in GitHub issues and PRs including the JIRA archive, the mailing lists, downstream trackers or the web. Closest, not this bug: #11990, which set today's sizing, and a downstream fix for the same pattern in the classic Highlighter.

Searches4 searches

Related, not this bug: #11990 (introduced Math.max(16, maxPassages); its review weighed queue size against the marker count and never discussed large values), #334 / LUCENE-10135 (maxPassageWindow overflow in the same method), elastic/elasticsearch#155507 (merged 2026-08-05: caps number_of_fragments because the classic Highlighter pre-sizes its fragment queue by it; a different class, fixed only downstream, does not name matchhighlight). No open PR touches PassageSelector.java or FieldValueHighlighters.java. Coverage limits: GitHub search matches whole tokens; closed unmerged PRs were checked by text only. No maintainer has commented on this bug.

What was searched, when, and what turned up
WhereWhenHowFound
Search tool (GitHub issues/PRs/commits incl. the JIRA archive, dev@, java-user@, issues@, commits@, downstream, web)2026-10-2977 queries: class and method names, symptoms and the error text, the general pre-size pattern276 distinct hits, none about this bug
GitHub (apache/lucene and downstream engines), JIRA2026-10-2922 manual apache/lucene queries (PassageSelector 0 items, maxPassages 7, pickBest 0) and 6 downstream ones; JIRA for the matchhighlight classes#11990, #334, #12881, elastic/elasticsearch#155507: related, none the same bug
Open PRs2026-10-29changed files of all 415 open apache/lucene PRsnone touches the two files
GitHub, re-checks2026-10-30 (twice)issue/PR search for PassageSelector, matchhighlight maxPassages, MatchHighlighter Integer.MAX_VALUE, pickBest maxPassages, maxPassages IllegalArgumentException, FieldValueHighlighters maxPassages; the PRs created since 2026-10-29; newest commits on PassageSelector.java0 results; only #16949 touches the highlighter module (a vectorhighlight javadoc); the file is unchanged on upstream main 2f424fd428da

History

No regression point: the queue has been sized by maxPassages since the class was added (LUCENE-9463, 2020-08-14), first released in 9.0.0. Every release from 9.0.0 to 10.6.0 and main fails; 8.x has no PassageSelector.

Versions tested5 rows

Introducing commit 150a8dacb59 (2020-08-14, LUCENE-9463, apache/lucene-solr#1750, Dawid Weiss): new PriorityQueue<>(maxPassages), sized only by maxPassages and built before the selection loop reads any marker (the markers were only sorted first). The MatchHighlighter path came with LUCENE-9464 (e2f3f626ee4, 2020-09-10), also before 9.0.0. #11990 (72ff140f5a6 on main, 07866ec2674 on branch_9x, Alan Woodward, 2022-12-01, 9.5.0) changed the size to Math.max(16, maxPassages) and added the result clamp: the same size for maxPassages >= 16. #14705, #14817 and #16255 (main only) kept the size argument. The exception is the same on every affected version, from pickBest:92 before 9.5.0 and :96 after. Old releases ran on JDK 25.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle; standalone -ea and -da)
10.6.0 (4ae946e5a9b9), 10.5.2, 9.12.3 affectedthe minimal test fails: the same IllegalArgumentException
9.0.0, 9.4.2, 9.5.0, 10.0.0, the 10.6 RC portable probepickBest(..., Integer.MAX_VALUE) throws, -ea and -da
8.6.1, 8.11.4 not affectedno PassageSelector
fix A (scratch worktree at 645d4fe14a20) not affectedthe minimal test and the whole highlighter suite pass

Independent checks

An independent reproducer with its own tests, an intended-behaviour devil's advocate, reading-only reviews by a model from another family, an adjudication and two re-runs confirm it. This doc re-ran the test on main, 10.6.0, 10.5.2 and 9.12.3 and validated the fix with gradle.

Checks and verdicts420 words

The cross-model reviews ran reading only and could not run Java; the refute lens therefore returned a procedural "not real" while agreeing on the root cause and that the inputs are in contract. No executing cross-model review exists.

Each independent check and its verdict
CheckVerdictWhat it found
finder (proof + translation validation) candidateHand proof and 31,086,300 / 24,980,382 checks on the stock jar, 0 failures, 3 mutants killed; its JUnit fails 3 of 3 on main (-ea, -da), 10.5.2 and 9.12.3; with the fix 3 of 3 pass, upstream TestPassageSelector 20/20 and TestMatchHighlighter 6/6.
cross-model review, refute lens (OpenAI GPT-5.6, reading only) procedural "not real" (low)Agreed on the root cause and that the inputs are in contract; rated the intended argument moderate. A separate run re-ran the repro: 3 of 3 fail.
independent reproducer realOwn 2-test JUnit (pickBest, and end to end through MatchHighlighter): 2 of 2 fail on main (-ea, -da), 10.5.2 and 9.12.3; its own fix passes; probe: 64 MiB at 2^24, OutOfMemoryError at 2^31 - 18, IllegalArgumentException from 2^31 - 17, same on 10.5.2 and 9.0.0.
cross-model review, intended lens (OpenAI GPT-5.6, reading only) uncertain (low)Rated "works as designed" moderate and noted that nothing documents a maximum or the pre-allocation; a separate run re-ran the repro: 3 of 3 fail.
intended-behaviour devil's advocate realBuilt the strongest works-as-designed case and answered it; re-ran the finder's JUnit: 3 of 3 fail on main (-ea, -da) and 10.5.2.
adjudication confirmed (medium), LowRe-ran the reproducer's test with a new seed: 2 of 2 fail (-ea, -da); no-noise gate passes on the Integer.MAX_VALUE leg only.
Registration check and credibility audit downgrade (impact class crash to wrong error; row kept)A fresh-seed re-run (5EED4410, -da) fails 1 of 1 and passes with the fix; the audit's re-run with seed DEADBEEF fails 2 of 2.
minimal-repro audit verifiedThis one test fails on main (-ea, -da, two seeds) and passes with the fix.
this doc reproduced, fix validatedGradle on a clean 645d4fe14a20: the test fails, and with fix A the whole highlighter suite (723 tests) and the format check pass; the test fails on main (-ea, -da), 10.6.0, 10.5.2 and 9.12.3 and passes on main with the fix (-ea, -da); the allocation probe gives the same numbers twice.

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

Finding record

LFV-1005, snapshot of 2026-11-06

Registry title
matchhighlight PassageSelector sizes its passage queue by maxPassages before it looks at any match, so maxPassages = Integer.MAX_VALUE ('all passages') throws IllegalArgumentException and a large maxPassages allocates maxPassages + 1 queue slots per call, even for a text with two matches
Area
highlighter / matchhighlight passage selection (PassageSelector.pickBest passage-queue sizing; used by MatchHighlighter through FieldValueHighlighters.highlighted, which passes maxPassages through unchecked)
Priority
Low, rank 646: This is the matchhighlight version of the UnifiedHighlighter maxPassages = Integer.MAX_VALUE row LFV-438; it fails loudly with a clear IllegalArgumentException in a little-used package, so it closes the MAX_VALUE pre-size group after LFV-995.
Impact class
wrong error
Impact note
Applications on any release from 9.0.0 to 10.6.0 or main that highlight with matchhighlight (MatchHighlighter with FieldValueHighlighters.highlighted, or PassageSelector.pickBest directly; default settings, any index, -ea and -da) and pass maxPassages = Integer.MAX_VALUE as 'all passages' (any value >= 2^31 - 17) get IllegalArgumentException 'maxSize must be >= 0 and < 2147483631' for every non-empty value, which fails the whole highlight call, and a large but accepted maxPassages costs one transient allocation of maxPassages + 1 queue slots per call however few matches there are (about 64 MiB at 2^24 for two matches; OutOfMemoryError at 3*10^8 with a 1 GiB heap); results are correct whenever the allocation succeeds, and nothing is written to the index.
Summary
You call PassageSelector.pickBest on a short text with two matches (or MatchHighlighter with FieldValueHighlighters.highlighted) and pass maxPassages = Integer.MAX_VALUE to mean 'all passages', and you expect the same two passages that maxPassages = 10 returns, because maxPassages only caps the result (the method clamps its result to it) and there can never be more passages than matches. Instead pickBest builds its priority queue with Math.max(16, maxPassages) slots before it reads any match, so the call throws IllegalArgumentException 'maxSize must be >= 0 and < 2147483631; got: 2147483647'. A smaller but still large maxPassages succeeds and allocates memory in proportion to maxPassages rather than to the matches: about 64 MiB per call at 2^24.
Introduced
150a8dacb59712c731e66db1a56068326a0439f4 (LUCENE-9463 'Query match region retrieval component, passage scoring and formatting', apache/lucene-solr#1750, Dawid Weiss, reviewed earlier by Alan Woodward in apache/lucene-solr#1721; JIRA issue now apache/lucene#10503) [read: git blame / log -S in the full-history clone; exec: 8.6.1 and 8.11.4 have no PassageSelector]. The class was created with 'new PriorityQueue<>(maxPassages)' built before any marker is read. #11990 (72ff140f5a6 on main, 07866ec2674 on branch_9x, Alan Woodward, 2022-12-01, first released in 9.5.0) changed the size to Math.max(16, maxPassages) and added the result clamp, which leaves the size unchanged for maxPassages >= 16; #14705, #14817 and #16255 (main only) kept the size argument. Not a regression. (2020-08-14)
First affected version
9.0.0 [exec: probe main() and the import-adapted JUnit fail with -ea and -da] (the first release with matchhighlight); 8.6.1 and 8.11.4 have no PassageSelector [exec: probe prints ABSENT]
Affected versions
every release that ships matchhighlight, 9.0.0 to 10.5.2, 10.6.0 (released 2026-10-29; run as its RC, branch_10_6 4ae946e5a9b) and main 645d4fe14a20 [exec: 9.0.0, 9.4.2, 9.5.0, 9.12.3, 10.0.0, 10.5.2, the 10.6 RC and main all fail (follow-up, a release-portable main() with -ea and -da plus the JUnit on most points); the finder's JUnit fails 3 of 3 on main (-ea, -da), 10.5.2 and 9.12.3; the independent reproduction's fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3] [read: all 33 releases/lucene/* tags from 9.0.0 on contain 150a8dacb59]; the same IllegalArgumentException on every affected version, from PassageSelector.pickBest:92 in 9.0.0-9.4.2 and :96 from 9.5.0 on; not affected: 8.x (no matchhighlight) [exec 8.6.1, 8.11.4]; not fixed upstream (PassageSelector.java:93 unchanged on upstream main 645d4fe14a20, author date 2026-10-28; the newest commit on the file is 54283150a81, 2026-06-14, unrelated; no open PR touches it). JDK 25.0.3 for all versions.
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
Upstream and prior reports
none found (2026-10-29, read-only; the prior-report record, verdict new, plus the finder's/ runs and the independent reproduction's, the intended-behaviour check's and the adjudication's searches): the scripted prior-report search (22 GitHub issue/PR, 5 commit, 4 downstream, 32 mailing-list over dev@, java-user@, issues@ and commits@, 8 JIRA-archive and 6 web queries; 276 distinct hits, 0 high-overlap), 23 manual apache/lucene GitHub searches ('PassageSelector' 0 items, 'maxPassages' 7 items, none a report; 'pickBest' 0; the PriorityQueue message 3, all unrelated), 6 downstream searches for MatchHighlighter and matchhighlight in Solr and Elasticsearch and one other downstream engine (0 each), every apache/lucene issue and PR created since 2026-10-26, the changed files of all 415 open PRs (none touches PassageSelector.java or FieldValueHighlighters.java), the review threads of #11990, #334, #12881, #14705, #14817 and apache/lucene-solr#1750, JIRA LUCENE-9461/9463/9464 and the 2025-11 dev@ highlighter thread. Recheck (2026-10-29): no apache/lucene item mentioning PassageSelector, matchhighlight or maxPassages was updated since 2026-10-27; none of the 13 PRs created since 2026-10-27 (up to #16938) concerns matchhighlight; the newest commit on PassageSelector.java is still 54283150a81 (2026-06-14); upstream main head is 645d4fe14a20. Closest items, not reports: #11990 (merged 2022-12-01, introduced the current Math.max(16, maxPassages) sizing; its review weighed queue size against the marker count and never discussed large maxPassages) and elastic/elasticsearch#155507 (merged 2026-08-05, caps number_of_fragments because the classic org.apache.lucene.search.highlight.Highlighter pre-sizes by it: the same bug class in a different class, fixed only downstream; it does not name matchhighlight or PassageSelector).
Lucene commit tested
main 645d4fe14a20 (11.0.0-SNAPSHOT): the minimal test with gradle in a clean scratch worktree (fails) and with fix A (passes, with the whole highlighter test suite and the format check), the standalone runner with -ea and -da (fails; passes with the fix), and an allocation probe without and with the fix; the 10.6.0 jars (commit 4ae946e5a9b9, -ea), 10.5.2 and 9.12.3 (-da): the minimal test fails. The history sweep ran a portable probe on 10 version points from 8.6.1 to main. PassageSelector.java is unchanged on upstream main 2f424fd428da (2026-10-30).

Lucene and upstream sources

Finding record
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/PassageSelector.java:67-74 (at 645d4fe14a20) and :76-84 (the two public pickBest overloads, no javadoc), :87-89 (early return for an empty value or a zero window), :91-93 ('int pqSize = Math.max(16, maxPassages);' with the #11990 comment), :96 (PriorityQueue.usingComparator(pqSize, passageScorer)), :98 (splitOrTruncateToWindows only after the queue exists), :103-166 (at most one insertWithOverflow per split marker), :213 ('last = Math.min(last, maxPassages)': maxPassages caps the result), :289 (pickDefaultPassage stops at size() >= maxPassages); byte-identical at a Lucene main checkout ea57360912ed
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/FieldValueHighlighters.java:88-115 (at 645d4fe14a20) (highlighted(maxPassageWindow, maxPassages, ...): javadoc does not mention maxPassages; passes it unchanged to pickBest at :109-110)
  • lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:34-35 (at 645d4fe14a20) (NOTE: pre-allocates maxSize + 1), :121-125 (IllegalArgumentException for maxSize >= ArrayUtil.MAX_ARRAY_LENGTH, 'to prevent confusing OOME'), :129-134 (otherwise heapSize = maxSize + 1 and new Object[heapSize])
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/MatchRegionRetriever.java:229-233 (at 645d4fe14a20) ('The predicate should return Integer#MAX_VALUE for all hits to be considered', the package convention for the sibling maxHitsPerField)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/PassageSelector.java:67-74, :76-84, :87-89, :91-93, :96, :98, :103-166 (:158), :213, :281-304 (:289)
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/FieldValueHighlighters.java:88-115 (:97-98, :109-110); lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/MatchHighlighter.java:264-266
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:34-35, :39, :61-64, :121-125, :129-134; lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:33-34
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/PassageSelector.java:213, :289, :103-166
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/TestPassageSelector.java:159-162 (largeWindow), :40, :48, :57, :69, :195 (maxPassages = 100), :322 (randomIntBetween(1, 10))
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/MatchRegionRetriever.java:230-233
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:757; uhighlight/FieldHighlighter.java:143

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro (lucene search, one match) and a probe executed for the diagrams on the stock main jars (assertions on and off), the 10.5.2 jar and with the fix. The diagram's probe measured 1,344 bytes at maxPassages = 10 with a different warm-up; this doc's table (552 bytes) comes from its own probe. Both give 4 MiB at 2^20 and 64 MiB at 2^24.

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.