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.
MatchHighlighter(packageorg.apache.lucene.search.matchhighlight) highlights query matches in stored field values. For each field with matches,FieldValueHighlighters.highlighted(maxPassageWindow, maxPassages, ...)asks aPassageSelectorfor the best passages.PassageSelector.pickBest(value, markers, maxPassageWindow, maxPassages)centres a window of at mostmaxPassageWindowcharacters on each match (a marker), keeps the best candidates in aPriorityQueue, removes overlaps and returns at mostmaxPassagespassages.- Lucene's
PriorityQueuehas 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.
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.- 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 andInteger.MAX_VALUEmeant as "all passages". - You expect the one passage 0-13 around the match:
maxPassages= 1, 10, 16 and 1000 all return exactly that. - You get
IllegalArgumentException: maxSize must be >= 0 and < 2147483631; got: 2147483647.MatchHighlighterwithFieldValueHighlighters.highlighted(20, Integer.MAX_VALUE, ...)fails the same way on a field with a match. - Why: before it reads a match,
pickBestcreates itsPriorityQueuewithMath.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. - 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.
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.
- 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 documentmaxPassagesand passes it unchanged topickBest(: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), thenPriorityQueue<Passage> pq = PriorityQueue.usingComparator(pqSize, passageScorer);(:96), and only thenmarkers = 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 throwsIllegalArgumentException("maxSize must be >= 0 and < " + ArrayUtil.MAX_ARRAY_LENGTH + ...)formaxSize >= ArrayUtil.MAX_ARRAY_LENGTH("Throw exception to prevent confusing OOME",:121-125), otherwise allocatesnew Object[maxSize + 1](:129-134).MAX_ARRAY_LENGTHisInteger.MAX_VALUE - NUM_BYTES_ARRAY_HEADER, 2,147,483,631 on the tested JVM.maxPassagesis otherwise only a cap. The selection loop makes at most oneinsertWithOverflowper split marker (:103-166, insert at:158); the result is clamped bylast = Math.min(last, maxPassages);(:213); with no candidate,pickDefaultPassagestops atdefaultPassages.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 = 10 | 1 passage, 552 bytes | 1 default passage, 544 bytes |
| 2^20 = 1,048,576 | 1 passage, 4 MiB | 1 passage, 4 MiB |
| 2^24 = 16,777,216 | 1 passage, 64 MiB | 1 passage, 64 MiB |
| 2^31 - 18 = 2,147,483,630 | OutOfMemoryError: Java heap space | same |
2,147,483,631 and up, incl. Integer.MAX_VALUE | IllegalArgumentException | same |
| any of these, with the fix | 1 passage, 552 bytes | 1 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 everypickBestcall on a non-empty value with a non-zero window, even when nothing matches, and so everyMatchHighlightercall on a document with a match in a highlighted field (a field without a match never reachespickBest). 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
maxPassageson every call (64 MiB at 2^24); with a 1 GiB heap, 3*10^8, 10^9 and 2^31 - 18 end inOutOfMemoryError(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
maxPassagesis 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
PassageSelectorin Solr or Elasticsearch;Integer.MAX_VALUEfails 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.
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.
/*
* 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());
}
}
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
pickBestreturns the 1 passage around the match (offsets 0-13), as it does formaxPassages= 10.- Observed
IllegalArgumentException: maxSize must be >= 0 and < 2147483631; got: 2147483647fromPriorityQueue.<init>viaPassageSelector.pickBest(PassageSelector.java:96).
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).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on 10.6.0, 10.5.2 and 9.12.3 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (plain int arithmetic, nothing platform-specific) |
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
# 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- Elsewhere
maxPassagesonly caps the result, and the queue never holds more than one passage per match. - The same method is meant to accept
Integer.MAX_VALUEas its window size; the package documents it as "all" formaxHitsPerField. - 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) anddefaultPassages.size() >= maxPassages(:289) work atInteger.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 hugemaxPassageWindow, and the upstream testTestPassageSelector.largeWindowpassesInteger.MAX_VALUEas 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'smaxHitsPerField: "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
maxPassagesas "The maximum number of top-N ranked passages" (UnifiedHighlighter.java:757) and bounds its queue withMath.min(64, maxPassages + 1)(FieldHighlighter.java:143; its own wrap atInteger.MAX_VALUEis a separate bug). - Tests. Upstream
TestPassageSelectorusesmaxPassagesfrom 1 to 100 (100 incheckEmptyExtra,checkOddOverlapsanddefaultPassages,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 suggestedmin(markers.size(), maxPassages * 3); Alan Woodward proposed the floor of 16 instead, and it was merged. LargemaxPassageswas 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 atmaxDocin the layer that knew the real bound; herepickBestknows 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).
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
maxPassagesthe 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 * 3from the #11990 review must avoidintoverflow: it wraps negative formaxPassages > 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:testis BUILD SUCCESSFUL: 35 suites, 723 tests, 0 failures, 0 errors, 0 skipped, among themTestPassageSelector20/20,TestMatchHighlighter6/6,TestMatchRegionRetriever22/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
-eaand-da; the allocation probe returns the same passage with 552 bytes allocated for everymaxPassagesup toInteger.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
maxPassagesvalues (0 to 20, around the bound beyond which the result cannot change, 4096, three random values below it, and 2^30,Integer.MAX_VALUE - 1andInteger.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.
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.
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.
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.
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, wheren = 1000returns 2 passages;n = 10^8allocated 381 MiB (maina9779ef3c193, 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_VALUEfailure 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| Search tool (GitHub issues/PRs/commits incl. the JIRA archive, dev@, java-user@, issues@, commits@, downstream, web) | 2026-10-29 | 77 queries: class and method names, symptoms and the error text, the general pre-size pattern | 276 distinct hits, none about this bug |
| GitHub (apache/lucene and downstream engines), JIRA | 2026-10-29 | 22 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 PRs | 2026-10-29 | changed files of all 415 open apache/lucene PRs | none touches the two files |
| GitHub, re-checks | 2026-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.java | 0 results; only #16949 touches the highlighter module (a vectorhighlight javadoc); the file is unchanged on upstream main 2f424fd428da |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails (gradle; standalone -ea and -da) |
10.6.0 (4ae946e5a9b9), 10.5.2, 9.12.3 | affected | the minimal test fails: the same IllegalArgumentException |
| 9.0.0, 9.4.2, 9.5.0, 10.0.0, the 10.6 RC | portable probe | pickBest(..., Integer.MAX_VALUE) throws, -ea and -da |
| 8.6.1, 8.11.4 | not affected | no PassageSelector |
fix A (scratch worktree at 645d4fe14a20) | not affected | the minimal test and the whole highlighter suite pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (proof + translation validation) | candidate | Hand 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 | real | Own 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 | real | Built 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), Low | Re-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 | verified | This one test fails on main (-ea, -da, two seeds) and passes with the fix. |
| this doc | reproduced, fix validated | Gradle 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 497 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,377 words
- 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-eaand-da(fails; passes with the fix), and an allocation probe without and with the fix; the 10.6.0 jars (commit4ae946e5a9b9,-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.javais unchanged on upstream main2f424fd428da(2026-10-30).
- 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 ea57360912edlucene/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-266a 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-166a 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-233a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:757; uhighlight/FieldHighlighter.java:143
- TestPassageSelectorMaxValueMaxPassages.java (lucene/highlighter, package org.apache.lucene.search.matchhighlight, 1 test):
repro/LFV-1005/src/org/apache/lucene/search/matchhighlight/TestPassageSelectorMaxValueMaxPassages.java(31 lines)
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.
assets/LFV-1005/bug.png(497 KB)
