All findings · Lucene finding · LFV-294 · priorityqueue-downheap-child-index-overflow

PriorityQueue.downHeap overflows its child index at node 2^30, so on queues of 2^30 or more elements updateTop, pop and insertWithOverflow can crash

Lucene's PriorityQueue computes a node's child as i << 1 in an int, so once a queue holds 2^30 (1,073,741,824) or more elements, a sift-down that reaches node 2^30 wraps the index to -2,147,483,647 and updateTop, pop or insertWithOverflow throws ArrayIndexOutOfBoundsException, losing an element. Every release since 1.3 has it.

Summary

Priority
Low, rank 1193PriorityQueue sift-down overflows only when a queue really holds 2^30 or more elements, an extreme-scale heap limit like LFV-069.
Impact
crash
Since
1.3 (2003); the code dates from the 2001 initial importintroduced 2001-09-18 · over 25 years
Affected
every release from 1.3 to 10.6.0, and main
Status
documented
Found by
semi-formal: a Dafny bounds model, then a JUnit test

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background: Lucene's PriorityQueue is a binary heap in an array

  1. PriorityQueue keeps 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.
  2. updateTop, pop and insertWithOverflow restore 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.
  3. Search uses subclasses: FieldValueHitQueue holds the hits of a field-sorted search, HitQueue those of an exact kNN search.
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.
The heap's left edge, nodes 1, 2, 4, ..., 2^30: at node 2^30 the child index 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.

The finding, step by step

  1. Create a PriorityQueue with 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).
  2. You expect the 1 to sink to a leaf in 30 steps and updateTop to return the new top, 0.
  3. 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 << 1 is 2^31, which does not fit in an int and wraps to -2,147,483,648.
  4. The negative index passes the k <= size check, so heap[-2147483647] is read: ArrayIndexOutOfBoundsException: Index -2147483647 out of bounds for length 1073741825. The 1 is gone and a 0 is now stored twice.
  5. With 2^30 - 1 elements the walk ends at node 2^29 and all is well. insertWithOverflow on a full queue takes the same updateTop path (run at 2^30 + 1), and pop can fail from 2^30 + 1 elements. On other data a call fails only if its sift-down reaches node 2^30.

Who is affected

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.

The code

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
}

The walk in the repro (2^30 zeros, the top replaced by 1)

  1. 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).
  2. At i = 2^29: j = 2^30 <= size and 0 < 1, so the 0 at node 2^30 moves up and i = 2^30.
  3. j = 2^30 << 1 = 2^31 does not fit in an int: it wraps to Integer.MIN_VALUE (-2,147,483,648), and k = j + 1 = -2,147,483,647 (:355-356).
  4. k <= size is true for any negative k (:357), so heap[-2147483647] is read and the JVM's bounds check throws ArrayIndexOutOfBoundsException.
  5. 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.

What bounds it

  • Threshold. The loop moves to i = j only while j <= size, so i reaches 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 i from 2^30 to 2^31 - 1, i << 1 is negative, so the code never reads or writes a wrong slot: once there, the bounds check always stops it.
  • Entry points. updateTop() and updateTop(T) (:286-295), and insertWithOverflow on a full queue (:238; run at 2^30 + 1, at 2^30 it is the same updateTop path), can fail from 2^30 elements. pop() decrements the size before downHeap(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). HitQueue and FieldValueHitQueue inherit downHeap.
  • Not affected. add walks up with j >>> 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.internal PriorityQueue at 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 at maxDoc (IndexSearcher.java:721-733) and each collector gets one FieldValueHitQueue of that size (TopFieldCollectorManager.java:112-114; FieldValueHitQueue extends PriorityQueue). The collector fills it with add (TopFieldCollector.java:438), calls updateTop once it is full (:449), and topDocs() 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 example Integer.MAX_VALUE as "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 risky pop returns normally). Before the queue exists, FieldValueHitQueue.create builds 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). exactSearch builds a pre-filled HitQueue(min(k, cost)) (:373-374) and calls updateTop for 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 HitQueue on 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) computes firstChild(i) = arity * (i - 1) + 2 in int: 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.

Minimal repro

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.

TestPriorityQueueTwoToThe30Elements.java31 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.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));
  }
}
Command
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 1073741825 at PriorityQueue.downHeap(PriorityQueue.java:357), with assertions on and off.

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

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=5g the 4 GiB array does not fit (OutOfMemoryError: Java heap space in 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 PriorityQueue is abstract and takes a lessThan override instead of usingComparator; with that one change the test fails on 10.6.0 the same way, at PriorityQueue.java:329.
  • As the regression test: this same test, for example as a method of TestPriorityQueue, marked @Monster("needs an 8 GB heap") like TestLongBitSet.testHugeCapacity. It passes with the fix. An 8 GB monster test is heavy; since heap and size are private, a cheap unit test would need a small refactor and would then test the arithmetic, not the real queue.

Environment

lucenemain 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)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
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)

Observed output

# 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

Why it looks unintended

  • 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 ArrayIndexOutOfBoundsException only for overfilling the queue.
  • java.util.PriorityQueue bounds 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") and 4c646dab3fb (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); updateTop returns "the new 'top' element" (:284); ArrayIndexOutOfBoundsException is 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 the IllegalArgumentException for Integer.MAX_VALUE.
  • The JDK bounds it. java.util.PriorityQueue's sift-down (siftDownComparable, siftDownUsingComparator) loops int 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 in LongBitSet.ensureCapacity and added a regression test (TestLongBitSet.testHugeCapacity), which was marked @Monster in 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.

Suggested fix

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.

A. Loop only while i <= size >>> 1 (recommended) (validated by the 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 lessThan calls in the same order, so results, including ties, are unchanged; each level still does one bound check (i <= lastParent instead of j <= size).
  • downHeap is 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:test passes (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.

B. Keep the loop, compare without overflow

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.

C. Lower the constructor limit to 2^30

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 note

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.

How it was found

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 PriorityQueue and TopDocs.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's int arithmetic into a separate bounds model with 32-bit wrap-around. Under size < 2^30 every heap[j] and heap[k] read verifies for Java's call sites (downHeap(1) and downHeap(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).

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26two 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 PRsno report; #13936 and #16277 rewrite downHeap and keep the int arithmetic
JIRA archive (LUCENE, SOLR)2026-10-268 REST queries, 9 issues read in fullLUCENE-504, LUCENE-2119, LUCENE-2406, SOLR-5661: constructor sizing only
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-26the scripted prior-report search's list queries plus 12 moreno report
Downstream and web (Solr, Elasticsearch, OpenSearch, Lucene.NET, Stack Overflow)2026-10-26GitHub searches, 15 web queries, 3 Stack Overflow questionsno report; elastic/elasticsearch#3599 is a comparator exception

History

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 -C and git 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 from 2 * maxSize + 1 to maxSize + 1 slots 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, the LessThan interface #14873, visibility #15493) kept the arithmetic, and every new sift-down caller inherited it (insertWithOverflow 2.3.0, updateTop 2.9.0, the addAll heapify 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 both j = i << 1 lines. Upstream main (2026-10-30), branch_11x and branch_10_6 still do.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20, a9779ef3c193, ea57360912ed affectedfails with assertions on and off (gradle and standalone); passes with the fix
10.6.0 affectedfails with assertions on and off, at PriorityQueue.java:329 (10.x API form of the test)
10.5.2, 9.12.3 affectedfails 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 affectedfails (release jars, assertions on)
1.3 tag source affectedfails: first release with maxSize + 1 slots
1.2 tag source overfill onlyfails only when more than maxSize elements are put into a queue of maxSize >= 2^29 + 1
control: 2^30 - 1 elements not affectedpasses on main, 10.5.2, 1.9.1, 1.3 and 1.2

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedDafny 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 verdictOpenAI GPT-5.6, refute and root-cause lenses: no answer.
independent reproducer realOwn 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 realNo not-a-bug argument survives; traced the sorted-search path by reading.
adjudication confirmed, highRe-ran the finder's test and its own pop probe; corrected the threshold to 2^30 or more.
fact checks real; wording correctedAn 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 reproducesFollow-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.

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

Finding record

LFV-294, snapshot of 2026-11-06

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:test and :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): main ea57360912ed and a9779ef3c193; 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 main 2f424fd428d (2026-10-30), branch_11x and branch_10_6 still have the same downHeap. All runs used OpenJDK 25.0.3.

Lucene and upstream sources

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)

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

Repro files

About the diagrams

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.