One line of integer arithmetic: downHeap computes the child of node i as i << 1 in a 32-bit int, which is wrong once i reaches 2^30.
PriorityQueuekeeps its least element on top. It stores a binary heap in an array from index 1: node i's children are at 2i and 2i + 1, so node 1's children are 2 and 3, node 2's are 4 and 5.updateTop,popandinsertWithOverflowrestore the order with a sift-down (downHeap): the top element moves down, swapping with its smaller child, until no child is smaller. That takes at most log2(size) steps, 30 for a billion elements.- Search uses subclasses:
FieldValueHitQueueholds the hits of a field-sorted search,HitQueuethose of an exact kNN search.
i << 1 wraps to a negative number and the next read throws. Select the still to open it at full size. Generated with AI, checked against the code.- Create a
PriorityQueuewith maxSize 2^30 = 1,073,741,824 (the constructor accepts sizes up to just under 2^31), fill it with 2^30 zeros, and replace the top with 1:pq.updateTop(1). - You expect the 1 to sink to a leaf in 30 steps and
updateTopto return the new top, 0. - The heap stores node i's children at 2i and 2i + 1. With equal children the 1 walks down the left edge: nodes 1, 2, 4, ..., 2^29, 2^30. At node 2^30 the child index
i << 1is 2^31, which does not fit in anintand wraps to -2,147,483,648. - The negative index passes the
k <= sizecheck, soheap[-2147483647]is read:ArrayIndexOutOfBoundsException: Index -2147483647 out of bounds for length 1073741825. The 1 is gone and a 0 is now stored twice. - With 2^30 - 1 elements the walk ends at node 2^29 and all is well.
insertWithOverflowon a full queue takes the sameupdateToppath (run at 2^30 + 1), andpopcan fail from 2^30 + 1 elements. On other data a call fails only if its sift-down reaches node 2^30.
Only queues holding 2^30 (1,073,741,824) or more elements, at least 4 GiB of references. Direct PriorityQueue users at that size fail once a sift-down reaches the deepest level. Through search, by code reading: a sorted search for 2^30 or more hits (data-dependent) or a filtered exact kNN search with k >= 2^30, on an index of over 2^30 documents.
Mechanism, terms and numbers726 words
PriorityQueue.downHeap computes the child index as int j = i << 1; int k = j + 1;. The constructor accepts any maxSize below ArrayUtil.MAX_ARRAY_LENGTH (2,147,483,631 with compressed references, 2,147,483,623 without), but once a queue holds 2^30 (1,073,741,824) or more elements, a sift-down can reach node 2^30, where i << 1 wraps to Integer.MIN_VALUE. Then k = -2147483647 passes k <= size, heap[k] throws ArrayIndexOutOfBoundsException, and because the children on the path were already moved up and the sifted element is never written back, the queue keeps its size with one element lost and another duplicated.
updateTop and insertWithOverflow can fail from exactly 2^30 elements, pop from 2^30 + 1; 2^30 - 1 always works. A call fails only when its sift-down reaches node 2^30, as it does in the repro. The bug is in every release since 1.3 (2003) and on main, with assertions on or off, and needs no setting. Through the search API it is reachable, by code reading only, from a field-sorted search for 2^30 or more hits (depending on the sort values) or a filtered exact kNN search with k >= 2^30, on an index of more than 2^30 documents; there it fails the request rather than returning wrong hits. Looping only while i <= size >>> 1, like the bound in java.util.PriorityQueue's sift-down, fixes it and passes Lucene's core tests.
protected void downHeap(int i) { // PriorityQueue.java:345
T node = heap[i]; // save top node
int j = i << 1; // find smaller child // :347
int k = j + 1; // :348
if (k <= size && lessThan.lessThan(heap[k], heap[j])) { // :349
j = k;
}
while (j <= size && lessThan.lessThan(heap[j], node)) { // :352
heap[i] = heap[j]; // shift up child // :353
i = j;
j = i << 1; // :355
k = j + 1; // :356
if (k <= size && lessThan.lessThan(heap[k], heap[j])) { // :357
j = k;
}
}
heap[i] = node; // install saved node // :361
}- All children are equal, so
lessThan(heap[k], heap[j])is false and the walk always takes the left child: node 1, 2, 4, 8, ..., 2^29, 2^30. Each step moves a 0 up one level (:353). - At
i = 2^29:j = 2^30 <= sizeand0 < 1, so the 0 at node 2^30 moves up andi = 2^30. j = 2^30 << 1= 2^31 does not fit in anint: it wraps toInteger.MIN_VALUE(-2,147,483,648), andk = j + 1 = -2,147,483,647(:355-356).k <= sizeis true for any negativek(:357), soheap[-2147483647]is read and the JVM's bounds check throwsArrayIndexOutOfBoundsException.heap[i] = node(:361) never runs. The 1 is gone; the 0 of node 2^30 now also sits at node 2^29. The queue still reports 2^30 elements.
- Threshold. The loop moves to
i = jonly whilej <= size, soireaches 2^30 only when the queue holds 2^30 or more elements. Every smaller queue is unaffected. At or above that size a call fails only if its sift-down actually reaches node 2^30 or deeper, which depends on the values; in the repro (equal elements, a larger new top) it does. - Always an exception. For every
ifrom 2^30 to 2^31 - 1,i << 1is negative, so the code never reads or writes a wrong slot: once there, the bounds check always stops it. - Entry points.
updateTop()andupdateTop(T)(:286-295), andinsertWithOverflowon a full queue (:238; run at 2^30 + 1, at 2^30 it is the sameupdateToppath), can fail from 2^30 elements.pop()decrements the size beforedownHeap(1)(:259-260), so it needs 2^30 + 1 elements before the pop.addAll's heapify (:171-173,:203-205) runs the same code (not executed).HitQueueandFieldValueHitQueueinheritdownHeap. - Not affected.
addwalks up withj >>> 1(upHeap,:332-343). - Allowed sizes. The constructor rejects only
maxSize < 0 || maxSize >= ArrayUtil.MAX_ARRAY_LENGTH(:121-125), so every size from 2^30 to 2,147,483,630 is accepted (with compressed references; 2,147,483,622 without them).
Exposure and likelihood533 words
- Direct users of the public,
@lucene.internalPriorityQueueat 2^30 or more elements: executed, fails with assertions on and off. - Field-sorted search (main and 10.x; by code reading, not executed).
IndexSearcher.search(query, n, sort)caps n atmaxDoc(IndexSearcher.java:721-733) and each collector gets oneFieldValueHitQueueof that size (TopFieldCollectorManager.java:112-114;FieldValueHitQueue extends PriorityQueue). The collector fills it withadd(TopFieldCollector.java:438), callsupdateToponce it is full (:449), andtopDocs()pops every entry (:458-463). Without an executor all segments form one slice (IndexSearcher.java:226-230). So a sorted search for n >= 2^30 hits (for exampleInteger.MAX_VALUEas "all hits") that collects more than 2^30 hits in one slice of an index with more than 2^30 documents can throw. It does whenever a sift-down reaches node 2^30 or deeper, which depends on the sort values (entries are ordered by value, then by doc, so there is no left edge of equal elements); it becomes likely once the queue holds well over 2^30 entries, and with just over 2^30 the search can complete normally (an independent fact check executed a valid heap of 2^30 + 1 elements whose one riskypopreturns normally). Before the queue exists,FieldValueHitQueue.createbuilds every sort field's comparator with n slots (FieldValueHitQueue.java:139-153), so a search may run out of memory there first. - Exact kNN search (by code reading), only with a filter: an unfiltered query takes the approximate path (
AbstractKnnVectorQuery.java:252-255); exact search runs when the filter matches at most the per-segment k (:282-285) or as a fallback (:301).exactSearchbuilds a pre-filledHitQueue(min(k, cost))(:373-374) and callsupdateTopfor each better hit (:397). With k >= 2^30 and a filter that matches 2^30 or more documents of one segment, the first competitive hit sinks down the left edge of equal sentinels and throws. - Relevance search used a pre-filled
HitQueueon 9.x (by reading); on main it uses a different heap (TernaryLongHeap), which has its own, separately tracked overflow. - Scale. 2^30 references are 4 GiB with compressed references; in the collectors each slot also holds an entry object and the sort comparators keep their own n-slot arrays, so tens of GB of heap. Lucene allows indexes of up to 2^31 - 129 documents (
IndexWriter.MAX_DOCS) and tests such indexes in its monster tests. - Effect. Through Lucene's collectors the request fails with an exception; the damaged queue belongs to that request and is discarded, so no wrong, extra or missing hits are returned, nothing hangs, nothing is written. A direct caller that catches the exception and keeps using the queue would read the lost and duplicated elements.
- Likelihood: contrived. It needs a billion-entry queue, and the searches of 2026-10-26 found no earlier report.
- Looking ahead. Open draft PR #16277 (a 3-ary queue for
FieldValueHitQueue, implementing #16076) computesfirstChild(i) = arity * (i - 1) + 2inint: it keeps the overflow, and for arity 3 it would start at node 715,827,883, so at about 716 million elements (by code reading). Open draft PR #16847 (more than 2^31 documents per index) makes very large indexes a first-class case.
One JUnit test, public API only, deterministic: a queue of 2^30 zeros, then updateTop(1). It allocates a 4 GiB array, so gradle needs -Ptests.heapsize=8g.
/*
* 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.util;
import java.util.Collections;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestPriorityQueueTwoToThe30Elements extends LuceneTestCase {
public void testUpdateTop() {
int n = 1 << 30;
PriorityQueue<Integer> pq = PriorityQueue.usingComparator(n, Integer::compare);
pq.addAll(Collections.nCopies(n, 0));
// expected: 1 sinks to a leaf and the new top is 0; actual: AIOOBE, index -2147483647
assertEquals(0, (int) pq.updateTop(1));
}
}
cp $REPRO/TestPriorityQueueTwoToThe30Elements.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.util.TestPriorityQueueTwoToThe30Elements -Ptests.seed=DEADBEEF -Ptests.heapsize=8g
# assertions off: add -Ptests.asserts=false (same result)
- Expected
updateTop(1)returns 0: the 1 sinks to a leaf in 30 steps and the queue keeps its 2^30 elements.- Observed
ArrayIndexOutOfBoundsException: Index -2147483647 out of bounds for length 1073741825atPriorityQueue.downHeap(PriorityQueue.java:357), with assertions on and off.
More files, environment, full output309 words
- Smallest input. 2^30 is the smallest failing size; with 2^30 - 1 elements the same call returns 0. Every element is the same cached
Integer, so only the reference array is allocated. - Heap. Lucene's test JVM uses the parallel garbage collector, whose old generation is two thirds of the heap, so at
-Ptests.heapsize=5gthe 4 GiB array does not fit (OutOfMemoryError: Java heap spacein the constructor). With the JVM's default collector a 5 GB heap is enough; the test then takes about 14 s. - On 9.x and 10.x
PriorityQueueis abstract and takes alessThanoverride instead ofusingComparator; with that one change the test fails on 10.6.0 the same way, atPriorityQueue.java:329. - As the regression test: this same test, for example as a method of
TestPriorityQueue, marked@Monster("needs an 8 GB heap")likeTestLongBitSet.testHugeCapacity. It passes with the fix. An 8 GB monster test is heavy; sinceheapandsizeare private, a cheap unit test would need a small refactor and would then test the arithmetic, not the real queue.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); in its 10.x API form the test also fails on 10.6.0 (10.5.2 and 9.12.3 fail with the finder's and the independent reproduction's tests, see History) |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp $REPRO/TestPriorityQueueTwoToThe30Elements.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.util.TestPriorityQueueTwoToThe30Elements -Ptests.seed=DEADBEEF -Ptests.heapsize=8g
# assertions off: add -Ptests.asserts=false (same result)
# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.heapsize=8g, assertions on (the same with -Ptests.asserts=false)
TestPriorityQueueTwoToThe30Elements > testUpdateTop FAILED
java.lang.ArrayIndexOutOfBoundsException: Index -2147483647 out of bounds for length 1073741825
at __randomizedtesting.SeedInfo.seed([DEADBEEF:F963D09E6ACA5243]:0)
at org.apache.lucene.util.PriorityQueue.downHeap(PriorityQueue.java:357)
at org.apache.lucene.util.PriorityQueue.updateTop(PriorityQueue.java:287)
at org.apache.lucene.util.PriorityQueue.updateTop(PriorityQueue.java:294)
at org.apache.lucene.util.TestPriorityQueueTwoToThe30Elements.testUpdateTop(TestPriorityQueueTwoToThe30Elements.java:29)
# 10.6.0 (10.x API form), assertions on and off
java.lang.ArrayIndexOutOfBoundsException: Index -2147483647 out of bounds for length 1073741825
org.apache.lucene.util.PriorityQueue.downHeap(PriorityQueue.java:329)
org.apache.lucene.util.PriorityQueue.updateTop(PriorityQueue.java:259)
org.apache.lucene.util.PriorityQueue.updateTop(PriorityQueue.java:266)
# with the fix: BUILD SUCCESSFUL, 1 test, assertions on and off- The constructor deliberately accepts sizes up to just under 2^31; it was tightened in 2014 and 2018 for clean errors, never limited to 2^30.
- The javadoc promises log(size) operations and documents
ArrayIndexOutOfBoundsExceptiononly for overfilling the queue. java.util.PriorityQueuebounds its sift-down likewise (half = n >>> 1); Lucene's call fails halfway, leaving the queue corrupt.
The full argument208 words
- The constructor allows these sizes. It rejects only
maxSize < 0 || maxSize >= ArrayUtil.MAX_ARRAY_LENGTH, with the comment "Throw exception to prevent confusing OOME" (PriorityQueue.java:121-125). SOLR-5661 (3ae719d025d, 2014, "catch too-large priority queue") and4c646dab3fb(2018, "Fix PriorityQueue constructor to throw IllegalArgumentException if you pass Integer.MAX_VALUE as maxSize") tightened it to give clean errors at extreme sizes. Each time the limit was the JVM array limit, never 2^30. - The contract. "Put()'s and pop()'s require log(size) time" (
:31-32);updateTopreturns "the new 'top' element" (:284);ArrayIndexOutOfBoundsExceptionis documented only for adding more than maxSize elements (:155-159,:186-190,:210-211). Here size <= maxSize. - No test or comment sets a 2^30 limit. The only test at an extreme size,
TestPriorityQueue.testMaxIntSize, checks theIllegalArgumentExceptionforInteger.MAX_VALUE. - The JDK bounds it.
java.util.PriorityQueue's sift-down (siftDownComparable,siftDownUsingComparator) loopsint half = n >>> 1; while (k < half)(JDK 25 source,PriorityQueue.java:686-687,:705-706), the analogous non-leaf guard for its 0-based heap. - Lucene fixes extreme-scale overflows. LUCENE-6409 (
69af3beaed8, 2015) fixed an integer overflow inLongBitSet.ensureCapacityand added a regression test (TestLongBitSet.testHugeCapacity), which was marked@Monsterin 2022 for its memory use (#11844).
The devil's-advocate check262 words
Strongest case for "not a bug": nobody holds a billion elements in a PriorityQueue; the class is @lucene.internal; it fails loudly with an exception; a 2009 comment says it is "very unlikely in practice" that one will insert this many objects (d2bf223b21c, LUCENE-2119); participants in LUCENE-504 said callers must size queues sensibly; and the only public search trigger needs an index of more than 2^30 documents and a sorted search for more than 2^30 hits on one slice, roughly 50-100 GB of heap.
Why it fails: @lucene.internal renders as "This API is for internal purposes only and might change in incompatible ways in the next release", a compatibility note, not a correctness waiver, and the stable IndexSearcher.search(Query, int, Sort) reaches the class. The 2009 comment was about a different off-by-one (the missing +1 slot at maxSize == Integer.MAX_VALUE), never about downHeap, and explicit validation replaced it in 2014 and 2018. The call does not fail cleanly: it throws halfway and leaves the queue corrupt. And passing Integer.MAX_VALUE for "all hits" is a supported idiom: LUCENE-2119 (ae72952fa41) added the maxDoc cap so it works. "Size queues sensibly" is advice to callers; the constructor's validation defines the accepted sizes, and IndexSearcher itself sizes the queue from the caller's n capped at maxDoc.
What it does establish: the priority is low, and the practical case for "won't fix" is substantial. A maintainer could reasonably call it theoretical, or take only the one-loop fix without an 8 GB test. That does not make the behaviour intended or documented.
Recommended A. Loop only while i <= size >>> 1 (recommended)
Loop only over nodes that have a left child, while (i <= size >>> 1), so i << 1 never exceeds size and cannot overflow. The same comparisons run in the same order; it is the same kind of guard java.util.PriorityQueue uses. Validated: the test passes, and so does all of :lucene:core:test.
All options and trade-offs3 options ยท 440 words
Three options; option A was applied in a scratch worktree of main 645d4fe14a20 and run against the minimal test, the closest suites, all core tests and core's static checks.
In downHeap only (+10/-10 lines). Nodes 1 to size/2 are exactly the nodes with a left child, so for them i << 1 <= size and k <= size + 1, and size < ArrayUtil.MAX_ARRAY_LENGTH < Integer.MAX_VALUE: neither can overflow. The loop exits when no child is smaller, as before.
protected void downHeap(int i) {
T node = heap[i]; // save top node
// nodes 1..size/2 have a left child; for them i << 1 <= size cannot overflow
final int lastParent = size >>> 1;
while (i <= lastParent) {
int j = i << 1; // find smaller child
int k = j + 1;
if (k <= size && lessThan.lessThan(heap[k], heap[j])) {
j = k;
}
if (lessThan.lessThan(heap[j], node) == false) {
break;
}
heap[i] = heap[j]; // shift up child
i = j;
}
heap[i] = node; // install saved node
}Trade-offs
- Same
lessThancalls in the same order, so results, including ties, are unchanged; each level still does one bound check (i <= lastParentinstead ofj <= size). downHeapis a hot path (every competitive hit of a sorted search). No extra work is expected, but performance was not measured; Lucene asks that robustness fixes not slow down hot paths.
What the checks validated
- The minimal test passes with assertions on and off (gradle, scratch worktree of
645d4fe14a20; also standalone). - Closest suites pass (259 tests):
TestPriorityQueue,TestTopFieldCollector*,TestTopDocsMerge,TestTopDocsCollector,TestSort*,TestFieldSortOptimizationSkipping,TestKnn*VectorQuery. - All of
:lucene:core:testpasses (8,897 tests, 322 skipped, 0 failures), and so does:lucene:core:check -x test(formatting, ecj lint, forbidden APIs, license checks). - Earlier, on main
ea57360912ed: the finder's test and the version probe pass.
Compute j and k in long, or test Integer.compareUnsigned(k, size) <= 0.
Trade-offs
Smallest diff, but every iteration gains a widening or an unsigned compare in the hot loop, and the code reads worse. Not validated.
Reject maxSize >= 2^30 with IllegalArgumentException.
Trade-offs
Turns the crash into a clean error, but shrinks the documented range; and because IndexSearcher sizes the queue at min(n, maxDoc), a sorted search with n = Integer.MAX_VALUE on an index of more than 2^30 documents would then be refused even when few documents match. Not recommended; not validated.
Downstream services that forward a client-chosen hit count (or kNN k) without a limit can see this only as a failed request, and only on an index of more than 2^30 documents.
Semi-formal. An AI tool wrote a Dafny model of downHeap's 32-bit index arithmetic to prove every array read in bounds; the proof went through only with the extra assumption size < 2^30, and without it Dafny flagged exactly the heap[j] and heap[k] reads. A JUnit test on the stock build then reproduced the crash.
The method in full142 words
- The hunt unit covered
PriorityQueueandTopDocs.merge. A functional Dafny proof of the heap (39 obligations, 0 errors, 5 of 5 vacuity mutants killed) uses unbounded integers and cannot see overflow. - So the finder transcribed
downHeap'sintarithmetic into a separate bounds model with 32-bit wrap-around. Undersize < 2^30everyheap[j]andheap[k]read verifies for Java's call sites (downHeap(1)anddownHeap(i <= size / 2)); with only the constructor's limit, Dafny reports the reads inside the loop as possibly out of bounds (5 verified, 3 designed errors). - A public-API JUnit test on the stock main build then failed with
Index -2147483647, and a state probe showed the lost element. Independent verifiers reproduced it with their own tests, refined the threshold to exactly 2^30, and searched for any statement that the limit is intended (none found).
No report found (searched 2026-10-26): 214 queries over GitHub issues and PRs, the JIRA archive, the mailing lists, downstream projects and the web; 47 hits read, none the same bug. Open PR #16277 would keep the overflow in its 3-ary queue.
Searches4 searches
Closest items, none the same bug: #1581 (LUCENE-504), LUCENE-2119 and #3481 (LUCENE-2406), a constructor heap-size overflow at maxSize = Integer.MAX_VALUE; SOLR-5661 and 4c646dab3fb, the constructor validation that defines the accepted range; open #13936 "Speedup PriorityQueue a little", which keeps int j = i << 1; open #16277 (3-ary queue, #16076), which keeps the overflow; elastic/elasticsearch#3599, where downHeap appears in a stack trace but the exception comes from that project's comparator. #13936 and #16277 were still open on 2026-11-01. Gaps: GitHub search returns at most 50 results per query (three broad queries truncated, covered by targeted ones), and private security reports cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-26 | two runs of the scripted prior-report search plus 35 direct searches, for example "downHeap", "i << 1", "Index -2147483647", "out of bounds for length 1073741826", PriorityQueue overflow; the changed files of all 412 open PRs | no report; #13936 and #16277 rewrite downHeap and keep the int arithmetic |
| JIRA archive (LUCENE, SOLR) | 2026-10-26 | 8 REST queries, 9 issues read in full | LUCENE-504, LUCENE-2119, LUCENE-2406, SOLR-5661: constructor sizing only |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-26 | the scripted prior-report search's list queries plus 12 more | no report |
| Downstream and web (Solr, Elasticsearch, OpenSearch, Lucene.NET, Stack Overflow) | 2026-10-26 | GitHub searches, 15 web queries, 3 Stack Overflow questions | no report; elastic/elasticsearch#3599 is a comparator exception |
In downHeap since the oldest commit that contains the code (2001, an import of pre-existing code); reachable within the documented capacity since 1.3 (2003), when the array became maxSize + 1 slots. It fails on every version run, from 1.3 to 10.6.0 and main (1.2 only when overfilled), and was never fixed.
Versions tested7 rows
- Introduced:
bd3948c539e"Initial revision" (2001-09-18), the oldest commit that contains this code, an import of pre-existing code with no issue or PR (the repository root and two other 2001 commits come before it).git blame -w -M -Candgit log --follow -S 'j = i << 1'find only this commit. - Reachable within the documented capacity since
2e082f2f008(2003-09-11, "Added insert(Object) method to PriorityQueue class"), which changed the array from2 * maxSize + 1tomaxSize + 1slots and documented maxSize as the capacity: first release 1.3. In 1.2 the same code fails only on an overfilled queue. - Not a regression: later edits to
downHeap(generics LUCENE-1935, a start-index parameter LUCENE-6066, theLessThaninterface #14873, visibility #15493) kept the arithmetic, and every new sift-down caller inherited it (insertWithOverflow2.3.0,updateTop2.9.0, theaddAllheapify 9.2.0). - All 166 release tags in the full history (
releases/lucene*,releases/lucene-solr*; there are none for 8.8.2 to 8.11.4) contain bothj = i << 1lines. Upstream main (2026-10-30),branch_11xandbranch_10_6still do.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20, a9779ef3c193, ea57360912ed | affected | fails with assertions on and off (gradle and standalone); passes with the fix |
| 10.6.0 | affected | fails with assertions on and off, at PriorityQueue.java:329 (10.x API form of the test) |
| 10.5.2, 9.12.3 | affected | fails with assertions on and off (the finder's, the independent reproduction's and the history probe's tests) |
| 10.0.0, 9.0.0, 8.11.4, 7.7.3, 7.5.0, 7.4.0, 6.6.6, 5.5.5, 4.10.4, 4.7.0, 4.6.1, 4.0.0, 3.6.2, 3.0.3, 2.9.4, 1.9.1 | affected | fails (release jars, assertions on) |
| 1.3 tag source | affected | fails: first release with maxSize + 1 slots |
| 1.2 tag source | overfill only | fails only when more than maxSize elements are put into a queue of maxSize >= 2^29 + 1 |
| control: 2^30 - 1 elements | not affected | passes on main, 10.5.2, 1.9.1, 1.3 and 1.2 |
The finder, an independent reproducer, an intended-behaviour devil's advocate and an adjudication each reproduced it; an independent fact check re-ran it, and it and a cross-model fact check corrected the wording. Re-run on three main commits and on 10.6.0.
Checks and verdicts257 words
The bug rests on the hunt's own AI verifiers, the adjudication, the fact checks and many executed repros. During the hunt the cross-model review (OpenAI GPT-5.6) gave no answer, which is not a refutation; a later cross-model fact check found the bug real and the fix sound.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | Dafny bounds model, then a public-API JUnit that fails on main, 10.5.2 and 9.12.3; the 2^30 - 1 control passes; a state probe shows the lost element. |
| cross-model review | no verdict | OpenAI GPT-5.6, refute and root-cause lenses: no answer. |
| independent reproducer | real | Own tests from outside Lucene's package: 6 of 8 fail through updateTop, pop, insertWithOverflow and HitQueue; exactly 2^30 already fails; the new top is lost. |
| separate re-run | reproduced (no cross-model verdict) | Fails at 2^30, passes at 2^30 - 1. |
| devil's advocate | real | No not-a-bug argument survives; traced the sorted-search path by reading. |
| adjudication | confirmed, high | Re-ran the finder's test and its own pop probe; corrected the threshold to 2^30 or more. |
| fact checks | real; wording corrected | An independent fact check re-ran the minimal test (standalone and gradle, assertions on and off) and the fix, and executed a counterexample showing the sorted-search failure depends on the data; a cross-model fact check (OpenAI GPT-5.6) found the bug real and the fix sound. Their corrections are applied. |
| re-runs | reproduces | Follow-up re-runs on ea57360912ed, the main refresh on a9779ef3c193, the minimal repro and this doc's gradle runs on 645d4fe14a20, and 10.6.0. |
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 401 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,481 words
- Registry title
- PriorityQueue.downHeap computes the child index i << 1 in int, so a queue holding 2^30 or more elements can throw ArrayIndexOutOfBoundsException (when a sift-down reaches node 2^30) from updateTop, pop or insertWithOverflow and is left with one element lost and another duplicated
- Area
- util / PriorityQueue (sift-down; base class of HitQueue and FieldValueHitQueue)
- Priority
- Low, rank 1193: PriorityQueue sift-down overflows only when a queue really holds 2^30 or more elements, an extreme-scale heap limit like LFV-069.
- Impact class
- crash
- Impact note
- Only queues that really hold 2^30 (1,073,741,824) or more elements, at least 4 GiB for the reference array alone, are hit: direct users of the @lucene.internal PriorityQueue at that size [exec], and by code reading a field-sorted IndexSearcher.search(query, n, sort) with n >= 2^30 (for example Integer.MAX_VALUE as 'all hits', capped at maxDoc) whose single slice collects 2^30 or more hits from an index of more than 2^30 documents, depending on the sort values (FieldValueHitQueue; main and 10.x), a relevance search for that many hits on 9.x (pre-filled HitQueue), or a filtered exact KNN search with k >= 2^30 whose filter matches 2^30 or more documents of one segment (pre-filled HitQueue) [read, not executed end to end]; no setting is needed, -ea and -da behave the same, every release since 1.3 and main is affected, and smaller queues are never affected.
- Summary
- You fill a Lucene PriorityQueue with 2^30 + 1 elements, a size its constructor accepts (any maxSize below ArrayUtil.MAX_ARRAY_LENGTH), and call updateTop, pop or insertWithOverflow; you expect the usual log(size) sift-down and the new least element back. Instead, when the sift-down reaches node 2^30, the child index i << 1 wraps to Integer.MIN_VALUE, the negative index -2147483647 passes the 'k <= size' check, and the call throws ArrayIndexOutOfBoundsException after children were already moved up, so the queue has lost the element being sifted and holds a second copy of another. A queue of 2^30 - 1 elements works; exactly 2^30 already fails for updateTop.
- Introduced
- bd3948c539ec1065db8b05b433ea55dbf844ff27 'Initial revision' (2001-09-18, svn r149570, the oldest commit that contains this code (its parent is 749b4aaf7e9; the repository roots are a0e7ee9d0d1 and 3cdb369a611): the import of the existing code base, with no issue or pull request) [exec: git blame -w -M -C on downHeap and git log --follow -S 'j = i << 1' in the full-history clone find only this commit]; reachable within the documented capacity since 2e082f2f008 (2003-09-11, svn r150020, 'Added insert(Object) method to PriorityQueue class', which changed the heap size from 2 * maxSize + 1 to maxSize + 1 and documented maxSize as the capacity; CHANGES 1.3 RC2 item 5) [read]. Not a regression: later changes to downHeap (generics LUCENE-1935 698b73e2ae1, start index LUCENE-6066 ef3a99b385f, LessThan interface #14873 39549329509, visibility #15493 dea98efd5e2, whitespace) left the index arithmetic alone, and each new sift-down entry point (insertWithOverflow 2.3.0, updateTop 2.9.0, addAll's heapify 9.2.0 #770) inherited it (2001-09-18 (bd3948c539e; the code may be older, its pre-import history is not in the repository); reachable within the documented capacity since 2003-09-11 (2e082f2f008))
- First affected version
- 1.3 [exec: tag source], the first release where it is reachable within the documented capacity (maxSize >= 2^30, size <= maxSize); the identical downHeap is already in 1.2, the first Apache release (2001-10-02, the same file as tag 1.0.1 and the initial revision), but there 2^30 elements can only be reached by calling put() more than maxSize times on a queue with maxSize >= 2^29 + 1 (allowed there: 2 * maxSize + 1 slots, no check) [exec: tag source; maxSize >= 2^30 throws NegativeArraySizeException there]
- Affected versions
- every release from 1.2 through 10.6.0 and main (ea57360912ed, a9779ef3c193, 645d4fe14a20); no version has a fix [exec: the minimal test fails with -ea and -da on main 645d4fe14a20 and, in its 10.x API form, on the 10.6.0 release jar; a history probe fails with ArrayIndexOutOfBoundsException 'Index -2147483647' from downHeap on main (-ea, -da), 10.5.2 (-ea, -da), 10.0.0, 9.12.3 (-ea, -da), 9.0.0, 8.11.4, 7.7.3, 7.5.0, 7.4.0, 6.6.6, 5.5.5, 4.10.4, 4.7.0, 4.6.1, 4.0.0, 3.6.2, 3.0.3, 2.9.4 and 1.9.1 (release jars, -ea unless stated) and on the 1.3 and 1.2 tag sources (1.2 by overfill only); controls with 2^30 - 1 elements pass on main, 10.5.2, 1.9.1, 1.3 and 1.2; the finder's JUnit also fails on main, 10.5.2 and 9.12.3, and the independent reproduction's on main, 10.5.2 and 9.12.3] [read: all 166 lucene and lucene-solr release tags in the history clone contain both 'j = i << 1' statements; 8.8.2 to 8.11.4 are untagged there, and the 8.11.4 jar was executed]. Caveat: every run used JDK 25.0.3
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, an intended-behaviour check, a prior-report search and a skeptical audit; no cross-model verdict (the reviewer was unavailable); not yet triaged by a person.
- Found by
- semi-formal: Dafny
- Upstream and prior reports
- none found (2026-10-26: 214 recorded queries, including two scripted prior-report searches (51 queries over apache/lucene issues, PRs and commits, downstream projects, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web, 349 hits, 0 high overlap; a second run of 45 queries with 1002 hits on the lists and JIRA, 0 high overlap, its GitHub part covered by direct searches), 35 direct GitHub searches over apache/lucene, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/lucenenet and GitHub-wide, 8 JIRA REST queries with 9 issues read in full, 12 more mailing-list queries, 15 web queries and 3 Stack Overflow questions; the changed files of all 412 open apache/lucene PRs; the intended-behaviour check's own prior-art run, 43 queries, 286 hits, 0 high; 47 hits read and classified, none the same bug); not fixed upstream (main and branch_10x still have 'int j = i << 1' in downHeap, checked 2026-10-26); closest items, not reports: #1581 / LUCENE-504, LUCENE-2119 and #3481 / LUCENE-2406 (constructor heap-size overflow at maxSize = Integer.MAX_VALUE, other code), SOLR-5661 and 4c646dab3fb (constructor validation that defines the accepted range the bug lives in), and the open PRs #13936 (keeps 'int j = i << 1') and #16277 for #16076 (a 3-ary queue whose firstChild(i) = arity * (i - 1) + 2 in int keeps the overflow, for arity 3 from i >= 715,827,883, by code reading); none mentions the overflow
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test with assertions on and off, standalone (2026-10-29) and through gradle in a scratch worktree (2026-11-01); the recommended fix in that worktree with the test, the closest suites, all of:lucene:core:testand:lucene:core:check -x test. Release 10.6.0 (Maven Central jar, SHA-1 checked), the same test in the 10.x API form, assertions on and off. Earlier hunt and follow-up runs (the finder's, the independent reproduction's and the history probe's tests, not the minimal test): mainea57360912edanda9779ef3c193; 10.5.2 and 9.12.3 (assertions on and off), 10.0.0, 9.0.0, 8.11.4, 7.7.3, 7.5.0, 7.4.0, 6.6.6, 5.5.5, 4.10.4, 4.7.0, 4.6.1, 4.0.0, 3.6.2, 3.0.3, 2.9.4 and 1.9.1 jars, and the 1.3 and 1.2 tag sources. Read: upstream main2f424fd428d(2026-10-30),branch_11xandbranch_10_6still have the samedownHeap. All runs used OpenJDK 25.0.3.
- Finding record
lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:30-39 (at ea57360912ed) (class javadoc: 'Put()'s and pop()'s require log(size) time'; @lucene.internal), :113-148 (constructor: :121-125 rejects only maxSize < 0 or >= ArrayUtil.MAX_ARRAY_LENGTH, 'Throw exception to prevent confusing OOME'; :129 heapSize = maxSize + 1; :139-147 sentinel pre-fill), :150-175 (addAll: AIOOBE documented only for more than maxSize elements; :171-173 heapify through downHeap), :224-243 (insertWithOverflow -> updateTop :238), :253-265 (pop -> downHeap(1) :260), :267-295 (updateTop, '@return the new top element'), :345-362 (downHeap: :347-348 and :355-356 'j = i << 1; k = j + 1' in int; :349 and :357 'k <= size' guard passes a negative k; :352-354 children shifted up; :361 saved node installed only after the loop)lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:719-733 (at ea57360912ed) (cappedNumHits = Math.min(numHits, maxDoc)), lucene/core/src/java/org/apache/lucene/search/TopFieldCollectorManager.java:112-114 (FieldValueHitQueue.create(sort, numHits)), lucene/core/src/java/org/apache/lucene/search/FieldValueHitQueue.java:30 (extends PriorityQueue), lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:458-463 (populateResults pops every entry), lucene/core/src/java/org/apache/lucene/search/AbstractKnnVectorQuery.java:373-374, :397 (exact search: pre-filled HitQueue of min(k, cost), updateTop per better hit) [read]
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:30-41, :113-148 (:121-125, :129, :134), :161-175, :192-207, :215-222, :231-243, :254-265, :267-295, :332-343, :345-362 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:32-34 (MAX_ARRAY_LENGTH) (at 645d4fe14a20)
- Who is affected
lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:226-230, :721-733 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/search/TopFieldCollectorManager.java:112-114; search/FieldValueHitQueue.java:30, :139-153; search/comparators/IntComparator.java:38; search/TopFieldCollector.java:438, :449, :458-463 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/search/AbstractKnnVectorQuery.java:252-255, :282-285, :301, :373-374, :397; search/TopScoreDocCollector.java:36, :44; index/IndexWriter.java:208 (at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:31-32, :39, :121-125, :155-159, :186-190, :210-211, :284 (at 645d4fe14a20)lucene/core/src/test/org/apache/lucene/util/TestPriorityQueue.java:378-382; lucene/core/src/test/org/apache/lucene/util/TestLongBitSet.java:354-366 (at 645d4fe14a20)JDK 25 src.zip java.base/java/util/PriorityQueue.java:686-687
- Suggested fix
SECURITY.md:46 (robustness improvements must not slow down hot paths)JDK 25 src.zip java.base/java/util/PriorityQueue.java:686-687, :705-706
- History
a Lucene clone: origin/main 2f424fd428d, origin/branch_11x 603af870375, origin/branch_10_6 7e5aec4370e, releases/lucene/10.6.0 (PriorityQueue.java read)
- TestPriorityQueueTwoToThe30Elements.java:
repro/LFV-294/TestPriorityQueueTwoToThe30Elements.java(31 lines)
assets/LFV-294/bug.png(401 KB)
![A sift-down in a PriorityQueue of 2^30 elements walking down the left edge of the heap, nodes 1, 2, 4, ..., 2^29, 2^30; at node 2^30 the child index 2^30 << 1 wraps to -2,147,483,648 in a 32-bit int, the negative index passes the 'k <= size' check, and reading heap[-2147483647] throws ArrayIndexOutOfBoundsException.](../assets/LFV-294/bug.png)