All findings · Lucene finding · LFV-069 · longheap-growth-overflow

LongHeap and TernaryLongHeap overflow int at 715,827,883 elements: push stops growing and IndexSearcher.search(query, n) can throw

LongHeap and TernaryLongHeap compute array positions in int and break at 715,827,883 values (a 5.73 GB long[]): push throws instead of growing as documented, and since 10.4.0 IndexSearcher.search(query, Integer.MAX_VALUE) on an index of that size, without an executor, throws after enough hits (202 million in the executed run). Extreme scale, loud failure, low impact.

Summary

Priority
Low, rank 1192LongHeap and TernaryLongHeap stop growing at about 715M elements and are left corrupt after the throw, an unrealistic heap size, and the fix touches a hot path.
Impact
crash
Since
9.0.0 for LongHeap.push (LUCENE-9626, 2020); 10.4.0 for TernaryLongHeap and the search path (#15140, 2025)introduced 2020-12-09 · about 5 years
Affected
9.0.0-10.6.0 and main; the search path 10.4.0-10.6.0 and main
Status
documented
Found by
formal: CodeQL inventory plus Z3 bit-vector queries

AI-assisted page: this finding was first signalled by a formal tool's own output 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

Two int multiplications by 3 in these heaps overflow at the same size, 715,827,883.

Background: how these heaps store values

  1. LongHeap (two children per node) and TernaryLongHeap (three) are min-heaps of long values kept in one array, 1-based: slot 0 is unused and the smallest value sits in slot 1.
  2. In TernaryLongHeap the children of slot i are slots 3(i-1)+2 to 3(i-1)+4. Sinking a value (updateTop, pop) walks down through the smallest child while that child is smaller, or until it reaches a slot whose first child would be past the last value: a leaf.
  3. push appends a value; when the array is full it first grows it to at least 1.5 times its length. The javadoc: "This heap provides unbounded growth via push(long)".
  4. Since 10.4.0 IndexSearcher.search(query, n) keeps the top n hits in a TernaryLongHeap of n slots filled with placeholders; every better hit replaces the top value and sinks down.
Diagram of the bug. A LongHeap array of 715,827,883 slots (5.73 GB) is full. push computes the new length (size * 3 + 1) / 2 in int: 715,827,883 * 3 + 1 = 2,147,483,650 wraps to -2,147,483,646, so the target is -1,073,741,823 instead of 1,073,741,825. ArrayUtil.grow fails its assertion (with -ea) or returns the same array (with -da), and push throws; size() is left one too large. In TernaryLongHeap, the heap of the top-hits collector, the first child of slot 715,827,883, 3 * 715,827,882 + 2 = 2^31, wraps to -2,147,483,648, so updateTop throws. Expected: the array grows to 1,207,959,553 slots.
How a full heap of 715,827,883 slots asks for a negative new length, so push throws, and how the same multiplication by 3 sends TernaryLongHeap's sift-down (the heap of the top-hits collector) to slot -2,147,483,648; then what you expect and what you get. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Fill a LongHeap (or TernaryLongHeap) with 715,827,882 values, for example with new LongHeap(715_827_882, 0L). Its array has 715,827,883 slots (slot 0 is unused), 5.73 GB, and is now full.
  2. Call push(1). The javadoc says push "Grows unbounded as needed", so you expect the array to grow to about 1.2 billion slots, or an OutOfMemoryError if the JVM heap is too small.
  3. Instead the new length (size * 3 + 1) / 2 is computed in int: 715,827,883 x 3 + 1 = 2,147,483,650 does not fit and wraps, giving -1,073,741,823. push throws (AssertionError with assertions on, ArrayIndexOutOfBoundsException: Index 715827883 with them off), and size() already counts the lost value.
  4. The same size breaks TernaryLongHeap's sift-down: the first child of node 715,827,883 is 3 x 715,827,882 + 2 = 2^31, which wraps to -2,147,483,648. The top-hits collector uses this heap, so on a searcher without an executor searcher.search(new MatchAllDocsQuery(), Integer.MAX_VALUE) on an index of 715,827,883 documents throws after 202,045,716 hits. With n one smaller the same collector collects all 715,827,883 hits (that control did not build the TopDocs).

Who is affected

Only at extreme scale: IndexSearcher.search(query, n) on 10.4.0+ with n of at least 715,827,883 on an index that large, once one collector (a searcher without an executor) has collected enough hits (202 million in the executed run); n = Integer.MAX_VALUE gets there. Also direct users of these internal heaps at that size, or an HNSW frontier above 715.8 million entries.

Mechanism, terms and numbers597 words

LongHeap and TernaryLongHeap document unbounded growth, but both compute array positions with int multiplications by 3 that overflow once the heap holds 715,827,883 values (a 5.73 GB long[]). push then throws instead of growing and leaves size() one too high, and TernaryLongHeap.downHeap reads a negative index. Since 10.4.0 the default top-hits collector keeps its n hits in a TernaryLongHeap, so on a searcher without an executor IndexSearcher.search(query, n) throws ArrayIndexOutOfBoundsException once n and the index both reach 715,827,883 documents and enough hits are collected (executed on main and 10.6.0). In every executed run nothing was written wrongly and no wrong result was returned; it is a loud failure at extreme scale. Introduced in 9.0.0 (LongHeap) and 10.4.0 (TernaryLongHeap); never fixed.

Push growth (LongHeap.java:76-84; TernaryLongHeap.java:77-81 are the same growth lines)

public long push(long element) {
  size++;                                              // size is now 715,827,883
  if (size == heap.length) {                           // heap.length is 715,827,883: full
    heap = ArrayUtil.grow(heap, (size * 3 + 1) / 2);   // int: 2,147,483,650 wraps to -2,147,483,646; /2 = -1,073,741,823
  }
  heap[size] = element;                                // index 715,827,883 in a long[715,827,883]
  • ArrayUtil.grow(long[], int) (ArrayUtil.java:409-414) asserts minSize >= 0 with the message "size must be positive (got -1073741823): likely integer overflow?" (:410). Without assertions a negative minSize is never larger than the array, so the same array comes back and the store one line later goes one past its end.
  • The exact target is 1,073,741,825, far below ArrayUtil.MAX_ARRAY_LENGTH (2,147,483,631 on a 64-bit JVM with compressed oops, as in every run here).
  • size++ runs first, so after the throw size() says 715,827,883 although only 715,827,882 values are stored; a later pop() throws ArrayIndexOutOfBoundsException (shown by the study's test).
  • Every array length from 715,827,883 (3 x 715,827,883 = 2^31 + 1) up to MAX_ARRAY_LENGTH - 1 fails. A heap grown only by push gets stuck between about 0.72 and 1.21 billion values, depending on its initial capacity, for example at 718,079,901 values from capacity 64 (computed with the real ArrayUtil.oversize for every capacity from 1 to 2,000,000).

Sift-down index (TernaryLongHeap.java:221-248)

int firstChild = arity * (i - 1) + 2;          // :225  i = 715,827,883: 3 * 715,827,882 + 2 = 2^31, wraps to -2^31
if (firstChild > size) break; // i is a leaf    // :226  a negative firstChild never stops the loop
...
long bestVal = heap[firstChild];               // :232  ArrayIndexOutOfBoundsException: Index -2147483648

Only a heap of at least 715,827,883 values has slot 715,827,883. It is a leaf there, but once the index has wrapped the leaf test cannot see it. updateTop (:151-155) and pop (:119-129) both sink values this way. From node 1,431,655,766 on the product wraps back to a positive index (205,032,703 for node 1,500,000,000), so with n above 1.43 billion the sift could break heap order silently instead of throwing (read, not run). The binary LongHeap.downHeap computes j = i << 1 (LongHeap.java:181, 189), which wraps from slot 2^30 = 1,073,741,824 (read, not run).

The search path

IndexSearcher.search(Query, int) (IndexSearcher.java:623-625, javadoc "Finds the top n hits") calls searchAfter, which caps n at maxDoc (:588, :597) and builds a TopScoreDocCollectorManager (:598-599). The manager only rejects numHits <= 0 (TopScoreDocCollectorManager.java:62). TopScoreDocCollector creates new TernaryLongHeap(numHits, LEAST_COMPETITIVE_CODE) (TopScoreDocCollector.java:44), a full heap of placeholders, and calls heap.updateTop(code) for every competitive hit (:130-135). With equal scores, ascending doc ids and one collector each hit sinks into the next free slot in post-order; slot 715,827,883 is the 202,045,716th, exactly where the executed search threw.

Exposure and likelihood428 words
  • Default collector, no special configuration. The default top-hits collector of IndexSearcher.search(query, n) uses TernaryLongHeap from 10.4.0 on (LongHeap in 10.3.x, HitQueue before). Lucene itself caps n at maxDoc, so Integer.MAX_VALUE reaches the heap as the document count. The caller still has to ask for n of at least 715,827,883.
  • Size. At least 715,827,883 documents in the searched view, about a third of IndexWriter.MAX_DOCS (Integer.MAX_VALUE - 128), and enough collected hits for the sift-down to reach slot 715,827,883. From the code: no update can write that slot without throwing, and every competitive hit replaces one placeholder, so in one collector (a searcher without an executor, or a slice that collects that many hits) a query that matches at least n (>= 715,827,883) documents always throws, at the latest at hit n; in the executed equal-score, single-collector run it threw at hit 202,045,716. With an executor each slice gets its own n-slot collector but only that slice's hits (segments above 250,000 documents get their own slice), so the executed 8-segment index, 83.2 to 93.2 million documents per segment, would not throw that way (not run).
  • Executed. 715,827,883 empty documents written with IndexWriter (8 segments, about 9.5 MB, 4.4 minutes), then searcher.search(new MatchAllDocsQuery(), Integer.MAX_VALUE) on new IndexSearcher(reader) (no executor) with an 8 GB JVM heap: ArrayIndexOutOfBoundsException: Index -2147483648 out of bounds for length 715827884 after 27.8 s and 202,045,716 collected hits, on main (assertions on and off) and on 10.6.0. With n = 715,827,882 the same collector collected all 715,827,883 hits, and with the fix n = 715,827,883 did too; these two runs used a counting CollectorManager that does not build TopDocs (one ScoreDoc per hit would need far more than 8 GB).
  • Other reach (read, not run). NeighborQueue pushes the HNSW search frontier into a TernaryLongHeap; reaching the push limit needs a frontier of more than 715.8 million entries in one segment. LongHeap's only in-tree users (the backward-codecs PForUtil copies) use 8 slots and never grow. External code may use these @lucene.internal heaps directly.
  • Cost of getting there. The collector pre-allocates a 5.73 GB long[] per collector (one per slice with an executor), and returning 715 million ScoreDocs needs far more, so few systems run such a query. But in one collector a bigger JVM heap does not help: at this size the collection throws.
  • What breaks. The query or the push throws; nothing is written. A heap whose push threw is left counting one value too many, so code that catches the exception and keeps using the heap reads past its end.

Minimal repro

One JUnit test on the public methods of the internal LongHeap: build a full heap of 715,827,882 values with new LongHeap(715_827_882, 0L), push one more, check size() and top(). Deterministic, single thread, about 4 s; it needs a 10 GB test heap for the 5.73 GB array.

TestLongHeapGrowthOverflow.java (JUnit, one test)31 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.util;

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

public class TestLongHeapGrowthOverflow extends LuceneTestCase {

  // push(long): "Grows unbounded as needed to accommodate new values."
  public void testPushGrowsFullHeap() {
    // long[715_827_883] holding 715_827_882 values: full, so the next push must grow the array
    LongHeap heap = new LongHeap(715_827_882, 0L);
    heap.push(1L);
    assertEquals(715_827_883, heap.size());
    assertEquals(0L, heap.top());
  }
}
Command
cp TestLongHeapGrowthOverflow.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestLongHeapGrowthOverflow -Ptests.seed=DEADBEEF -Ptests.heapsize=10g
# assertions off (production): add -Ptests.asserts=false (same bug, ArrayIndexOutOfBoundsException)
# with a fix the grown 9.66 GB array must fit next to the 5.73 GB one: use -Ptests.heapsize=24g
Expected
push(1) grows the array (to 1,207,959,553 slots) and size() is 715,827,883, or OutOfMemoryError if the heap is too small.
Observed
AssertionError: size must be positive (got -1073741823): likely integer overflow? from ArrayUtil.grow with assertions on; ArrayIndexOutOfBoundsException: Index 715827883 out of bounds for length 715827883 with them off.

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

More files, environment, full output382 words

TestLongHeapGrowthOverflow uses only the public methods LongHeap(int size, long initialValue), push, size and top of the @lucene.internal class; it sits in org.apache.lucene.util like TestLongHeap but touches no package-private member. The constructor builds the full array in one call, so there are no 715 million pushes. No randomness of its own.

Heap size. Local gradle test JVMs default to -XX:+UseParallelGC, whose old generation is two thirds of the heap, so the 5.73 GB array needs -Ptests.heapsize=10g; with 8 GB (or the default 512 MB) the test stops earlier with OutOfMemoryError in the constructor. A fixed push really allocates the grown 9.66 GB array while the old one is live, so the passing run needs about 24 GB.

As the regression test: this same test, for example as a method in TestLongHeap (perhaps marked @Monster for its memory need). It covers the push line only; a TernaryLongHeap twin differs only in the class name, and it does not cover the downHeap line the public search reaches. The search path needs a 715.8-million-document index (4.4 minutes to build), so it is evidence, not the repro.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same test fails on 10.6.0 and 10.5.2
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64; a 10 GB test heap
Command (environment)
cp TestLongHeapGrowthOverflow.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestLongHeapGrowthOverflow -Ptests.seed=DEADBEEF -Ptests.heapsize=10g
# assertions off (production): add -Ptests.asserts=false (same bug, ArrayIndexOutOfBoundsException)
# with a fix the grown 9.66 GB array must fit next to the 5.73 GB one: use -Ptests.heapsize=24g

Observed output

TestLongHeapGrowthOverflow > testPushGrowsFullHeap FAILED
    java.lang.AssertionError: size must be positive (got -1073741823): likely integer overflow?
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:CCE56747AD5487C5]:0)
        at org.apache.lucene.util.ArrayUtil.grow(ArrayUtil.java:410)
        at org.apache.lucene.util.LongHeap.push(LongHeap.java:79)
        at org.apache.lucene.util.TestLongHeapGrowthOverflow.testPushGrowsFullHeap(TestLongHeapGrowthOverflow.java:27)

# -Ptests.asserts=false:
TestLongHeapGrowthOverflow > testPushGrowsFullHeap FAILED
    java.lang.ArrayIndexOutOfBoundsException: Index 715827883 out of bounds for length 715827883
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:CCE56747AD5487C5]:0)
        at org.apache.lucene.util.LongHeap.push(LongHeap.java:81)
        at org.apache.lucene.util.TestLongHeapGrowthOverflow.testPushGrowsFullHeap(TestLongHeapGrowthOverflow.java:27)

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.heapsize=10g; 10.6.0 and 10.5.2: the same (ArrayUtil.java:411).

# The public search path (evidence, not the repro): 715,827,883 empty docs, search(new MatchAllDocsQuery(), Integer.MAX_VALUE), 8 GB heap:
maxDoc=715827883 numDocs=715827883 leaves=8
threw after 27825 ms
java.lang.ArrayIndexOutOfBoundsException: Index -2147483648 out of bounds for length 715827884
    at org.apache.lucene.util.TernaryLongHeap.downHeap(TernaryLongHeap.java:232)
    at org.apache.lucene.util.TernaryLongHeap.updateTop(TernaryLongHeap.java:153)
    at org.apache.lucene.search.TopScoreDocCollector$1.collectCompetitiveHit(TopScoreDocCollector.java:132)
    ...
    at org.apache.lucene.search.IndexSearcher.searchAfter(IndexSearcher.java:601)
    at org.apache.lucene.search.IndexSearcher.search(IndexSearcher.java:624)
# main, assertions on and off; 10.6.0 the same. With n = 715,827,882 the collector collects all 715,827,883 hits (counting run, no TopDocs built).

Why it looks unintended

  • Both classes promise growth: push "Grows unbounded as needed to accommodate new values", and the constructors accept capacities up to 2,147,483,630.
  • ArrayUtil.grow calls the negative target a "likely integer overflow?"; the correct target, 1,073,741,825, fits easily.
  • search(query, n) documents no limit besides the document count, and with n one smaller the same collector collects all hits.
The full argument182 words
  • Contract. Class javadocs LongHeap.java:24-25 and TernaryLongHeap.java:24-25; push javadoc :72 in both. The constructors accept initialCapacity in [1, MAX_ARRAY_LENGTH - 1] and throw early "to prevent confusing OOME" (:57-58). No size limit below MAX_ARRAY_LENGTH is documented anywhere.
  • Tests. TestLongHeap.testUnbounded and TestTernaryLongHeap.testUnbounded assert growth past the initial capacity (at small sizes); testInvalid rejects only -1, 0 and MAX_ARRAY_LENGTH.
  • Intent on record. In the #15140 review jpountz wrote "I had not realized that LongHeap grows unbounded on-demand. This doesn't feel right when the ctor takes a maxSize parameter"; the author replied that changing it "could break existing code that relies on unbounded growth. For instance, NeighborQueue", and the parameter was renamed initialCapacity. Growth is deliberate.
  • Precedent. Upstream fixed the same kind of overflow in other internal utilities: LUCENE-6409 "Fixed integer overflow in LongBitSet.ensureCapacity" (2015) and "Catch offset overflows in byte pool (#9660) (#12392)" (2023).
  • Effect. With n one smaller the same collector collects all 715,827,883 hits (the control skipped building the TopDocs), and the exact growth target fits: this is an arithmetic accident, not a capacity limit.
The devil's-advocate check179 words

Both heaps are @lucene.internal ("This API is for internal purposes only and might change in incompatible ways in the next release."). A reviewer from another model family (OpenAI GPT-5.6, asked to refute) reproduced the push failure, agreed on the root cause, and still judged it not a bug on that ground and because no public path had been run then. Also, 715 million hits is extreme, SECURITY.md leaves bounding inputs to the application, and the failure is loud.

Why it fails: @lucene.internal withdraws API stability, not correctness, and the precedents above are internal classes fixed as bugs. The decisive path uses only IndexWriter, DirectoryReader and IndexSearcher.search(Query, int) on a legal index with a legal n, executed on main and 10.6.0, with a control at n one smaller whose collection completes. (The one-test repro calls the internal LongHeap directly; the public path reaches the downHeap line, not push.) It is not resource exhaustion a limit would prevent: in one collector a bigger JVM heap does not avoid it, where Lucene's own constructors prefer clear errors over confusing ones.

Suggested fix

Recommended A. Growth target in long; downHeap stops at the last parent (both heaps)

Compute the growth target in long, capped at MAX_ARRAY_LENGTH, and stop downHeap at the last parent so no child index can wrap, in both heaps (2 files, +37/-17). With it the repro passes (assertions on and off), the collector collects all hits on the 715,827,883-document index, and the heap, collector and HNSW tests pass.

All options and trade-offs5 options ยท 898 words

Option A was validated through gradle in a scratch worktree of 645d4fe14a20 and on the public search path. B and C fix only push, D is a slower form of A, E narrows the contract. A workaround for users today: keep n at most 715,827,882, or page with searchAfter.

A. Growth target in long; downHeap stops at the last parent (both heaps) (validated by the checks) (recommended)

push in both classes asks ArrayUtil.grow for (int) Math.min((length * 3L + 1) / 2, ArrayUtil.MAX_ARRAY_LENGTH). TernaryLongHeap.downHeap loops only while i <= (size - 2) / arity + 1, the last parent, so firstChild is at most size and cannot wrap; LongHeap.downHeap does the same with size >>> 1, which also removes its i << 1 wrap from slot 2^30.

--- a/lucene/core/src/java/org/apache/lucene/util/TernaryLongHeap.java
+++ b/lucene/core/src/java/org/apache/lucene/util/TernaryLongHeap.java
   public long push(long element) {
     size++;
     if (size == heap.length) {
-      heap = ArrayUtil.grow(heap, (size * 3 + 1) / 2);
+      heap = ArrayUtil.grow(heap, nextHeapLength(size));
     }
@@
+  static int nextHeapLength(int length) {
+    // computed in long, length * 3 would overflow an int for large heaps
+    return (int) Math.min((length * 3L + 1) / 2, ArrayUtil.MAX_ARRAY_LENGTH);
+  }
@@ static void downHeap(long[] heap, int i, int size, int arity) {
     long value = heap[i]; // save top value
-    for (; ; ) {
+    // nodes after the parent of the last node are leaves; testing this instead of computing the
+    // first child of a leaf, which can overflow an int on large heaps
+    final int lastParent = size < 2 ? 0 : (size - 2) / arity + 1;
+    while (i <= lastParent) {
       // first child formula for 1-based indexing
       int firstChild = arity * (i - 1) + 2;
-      if (firstChild > size) break; // i is a leaf
-
       int lastChild = Math.min(firstChild + arity - 1, size);
# LongHeap: the same push change, and downHeap loops while (i <= size >>> 1)

Trade-offs

  • push's growth branch is cold. downHeap is hot (once per competitive hit in the collector, once per candidate pop in HNSW search), and SECURITY.md asks that robustness fixes not slow hot paths. An ABBA JMH run on aarch64 found no regression; the ternary sift-down was the same or slightly faster (ternaryUpdateTop, n = 10,000: 2,009.7 and 2,011.7 us/op before, 1,870.2 and 1,853.0 after). x86 was not measured.
  • A heap holding MAX_ARRAY_LENGTH - 1 values still cannot take one more (push then throws ArrayIndexOutOfBoundsException); that is the real array limit, and a clearer exception there is optional.
  • size++ still runs before the growth, so an OutOfMemoryError from grow also leaves size() one too high; moving it after the store is optional hardening.
  • Open draft PR #16847 puts a new DocScoreHeap into TopScoreDocCollector: its PackedDocScoreHeap still wraps a TernaryLongHeap, and its LongDocScoreHeap repeats int firstChild = ARITY * (i - 1) + 2; the same change would apply there. Open draft PR #12314 also edits LongHeap.push.
  • Tests: the one-test repro covers push only, not the TernaryLongHeap.downHeap line that the public search reaches (node 715,827,883 of a full heap). The LongHeap.downHeap change fixes a separate wrap (i << 1 from node 2^30, an 8.6 GB array); a narrower change could touch only both push methods and TernaryLongHeap.downHeap.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, 2026-11-01: without the fix the repro fails with assertions on and off (-Ptests.heapsize=10g); with it, it passes with assertions on and off (-Ptests.heapsize=24g).
  • With the fix: TestLongHeap (8 tests), TestTernaryLongHeap (8), TestTopDocsCollector (15), TestSearchAfter (1), TestTopDocsMerge (5) and every class of the org.apache.lucene.util.hnsw test package (10 classes, including TestNeighborQueue and the HNSW graph tests): 0 failures.
  • ./gradlew :lucene:core:check -x test with the fix and the repro as a new test file: passes.
  • On the 715,827,883-document index with the fix compiled over the stock main jars, the collector that search(query, n) uses (TopScoreDocCollectorManager(715,827,883, 1000)) collects all 715,827,883 hits in 93.6 s (counting run, TopDocs not built).

B. Fix only push (validated by the checks)

ArrayUtil.grow(heap, (int) Math.min((size * 3L + 1) / 2, ArrayUtil.MAX_ARRAY_LENGTH)) inline in push.

Trade-offs

Passes the repro, but leaves the downHeap overflow, so the search-path crash stays.

What the checks validated

The repro passes with it with assertions on and off (standalone, main 645d4fe14a20, 18 GB G1 heap).

C. ArrayUtil.grow(heap, size + 1) (not run)

Let ArrayUtil.oversize choose the growth; it over-allocates by about 1/8 and clamps at MAX_ARRAY_LENGTH, so nothing can wrap.

Trade-offs

Changes the growth factor from about 1.69x to about 1.125x, so heaps grown by push (HNSW frontiers) copy more often; not measured. Still needs the downHeap change.

What the checks validated

Not run.

D. Do downHeap's index arithmetic in long (validated by the checks)

Compute firstChild (and the child loop) in long and compare against size.

Trade-offs

Correct, but measured 15-16% slower on aarch64 JMH (ternaryUpdateTop, n = 10,000: about 2,000 to about 2,280 us/op; ternaryPushPop: 481 to about 558). Rejected for the hot path.

What the checks validated

JMH only (aarch64).

E. Document or enforce a size limit (not run)

For example reject n >= 715,827,883 in TopScoreDocCollectorManager and document a heap limit.

Trade-offs

Narrows the documented contract to hide an arithmetic bug. Not recommended.

What the checks validated

Not run.

Downstream note

Services that pass a caller-chosen result size straight to search(query, n) on an index of 715,827,883 or more documents get this exception; capping n at 715,827,882, or paging with searchAfter, avoids it.

How it was found

Formal. An AI tool listed every int multiplication or shift that range analysis could not bound (CodeQL), then asked Z3, over 32-bit wrapping arithmetic, for a heap length with a wrong growth target (0.01 s); a binary search over the solver gave the smallest, 715,827,883. An independent verifier reproduced it and found the sift-down twin.

The method in full143 words
  • Inventory: CodeQL over a traced build of lucene/core: every int * or << with an operand the range analysis cannot bound, in the util, packed, BKD, HNSW and codec packages (603 rows), blamed against history to prioritise.
  • Decide: Z3 4.12.2 with Java int as 32-bit bit-vectors that wrap, comparing the computed target with the exact value in a wider bit-vector; the first counterexample took 0.01 s, and a binary search over the solver gave the first failing length, 715,827,883; cvc5 agreed.
  • Witness and verification: a JUnit on the stock jar, then the study's independent verifier with its own 6-method test, an exhaustive arithmetic check over every length, and the TernaryLongHeap.downHeap twin. Later: a rerun on main b83101cdd2, a one-test minimal repro (2026-10-29), a re-verification on main, 10.5.2 and 10.6.0, a cross-model review and a second adjudication that executed the public search path (2026-11-01).

Prior reports, history and checks

Prior reports

No prior report: 88 GitHub search phrasings, a scripted prior-report search (GitHub, commits, Elasticsearch, OpenSearch and Solr trackers, mailing lists, JIRA archive, web), all 409 open PRs by touched file and about 30 threads read in full (2026-11-01). The closest, #15140, confirms that growth is intended. Not fixed upstream.

Searches6 searches

Closest items, none a report of this bug: #15140 (created TernaryLongHeap; its review discusses whether the heap should grow at all, not overflow), #10885 (open, the cost of LongHeap growth in HNSW search), #10666 / apache/lucene-solr#2108 (LUCENE-9626, introduced the expression), #11420 with #615 and #623 (simplified LongHeap, growth line untouched), #15163, #16153 and #14714 (moved callers onto these heaps), #11647 (pop() on an empty heap, a different bug), and open PRs #16847 (a new heap that repeats the sift-down expression) and #12314 (edits LongHeap.push). Gaps: two generic GitHub queries were cut at 50 and 63 results; private channels were not searched.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (incl. migrated JIRA); Elasticsearch, OpenSearch, OpenSearch k-NN, Solr2026-11-0188 GraphQL phrasings: class names with overflow, grow, push, downHeap, firstChild, ArrayIndexOutOfBoundsException; the expressions; the numbers 715827883, 715827884, 1073741823; the exception messages; the javadoc phrases189 distinct items, none this bug
GitHub, commits, downstream trackers, mailing lists, JIRA archive, web2026-11-01a scripted prior-report search: 34 issue/PR, 6 commit, 5 downstream, 20 list, 4 JIRA and 6 web queries; comments of the top 25 hits read442 distinct hits, none this bug
Open PRs2026-11-01all 409 open apache/lucene PRs by touched file#12314 touches LongHeap.java and 5 touch callers; none changes the growth or sift-down arithmetic
Threads read in full2026-11-01#15140, #15163, #16153, #14714, #10885, #11420, #615, #623, LUCENE-9626, #12255, #16847, #12314 and about 18 moreno discussion of int overflow in these heaps
Earlier searches2026-10-22 and 2026-11-01the study's 24 issue/PR phrasings; a scan of every issue and PR updated since 2026-10-20no report
Upstream main and branches2026-11-01blob compare of main f815e679bf6c, branch_11x and branch_10_6; CHANGES.txt; git log -Snot fixed

History

LongHeap's growth line came with LUCENE-9626 (af3e12265fe, 2020) and has failed in every release since 9.0.0. TernaryLongHeap copied it and added the sift-down index in #15140 (ac90517c17e), and the top-hits collector moved onto it in 10.4.0. Executed from 9.0.0 to 10.6.0 and main; never fixed.

Versions tested8 rows

LongHeap: LUCENE-9626 (af3e12265fe, apache/lucene-solr#2108, 2020-12-09) created it with (size * 3 + 1) / 2, at first growing only heaps created as growable; LUCENE-9644 (e1cd426bce3, 2020-12-22) made every heap grow and wrote the "unbounded growth" javadoc. First release 9.0.0 (no 8.x release has the class). LUCENE-10384 (#615, 9.1.0) simplified it and kept the line. TernaryLongHeap: #15140 (ac90517c17e, 2025-09-02; branch_10x 69e9ac67cfe) copied push and added firstChild = arity * (i - 1) + 2; first release 10.4.0. Callers: TopScoreDocCollector used HitQueue until 10.2.x, LongHeap in 10.3.x (#14714) and TernaryLongHeap from 10.4.0; NeighborQueue moved to TernaryLongHeap in #15163 (10.4.0) and UpdateGraphsUtils in #16153. Releases not run (9.2.0-9.12.2, 10.1.x, 10.2.0-10.2.1, 10.3.1-10.3.2, 10.5.0-10.5.1) have the same line.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (gradle and standalone); the search at 715,827,883 docs throws; with option A the repro passes and the collector collects all hits
10.6.0 affectedminimal repro fails (on and off); the search on an index written by 10.6.0 throws the same exception
10.5.2 affectedminimal repro fails with assertions on and off
10.4.0 first TernaryLongHeapTernaryLongHeap push and updateTop probes fail (on and off); the earlier one-test LongHeap repro fails
9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.2.2, 10.3.0 first affected 9.0.0version-adapted LongHeap variants (9.0.0-10.2.2) and the earlier one-test repro (10.3.0) fail at the final push
af3e12265fe introducing commitits LongHeap.java, compiled over 9.0.0, fails at the same boundary
8.11.0; 10.3.2 not affectedno LongHeap class; no TernaryLongHeap class (compile error)
upstream main f815e679bf6c, branch_10_6 readthe same two lines (source read, not executed)

Independent checks

Confirmed by the study's independent verifier, a rerun on a later main, a one-test minimal repro, a re-verification on main, 10.5.2 and 10.6.0, and a tie-break that ran the search on a real 715,827,883-document index. A reviewer from another model family (OpenAI GPT-5.6) reproduced it but judged it not a bug (internal API, no public path yet).

Checks and verdicts380 words

No check disputed the root cause. The one dissent was about the contract, and the executed public path answers it.

Each independent check and its verdict
CheckVerdictWhat it found
finder: CodeQL + Z3 first wrapping length 715,827,883603-row inventory of unbounded int arithmetic; a Z3 bit-vector query found a counterexample in 0.01 s and a binary search over the solver the smallest length; cvc5 agrees.
independent verifier (study) reproducedIts own 6-method JUnit on main 7cecf79523, assertions on and off: both heaps throw at the boundary, the heap is left with size() 715,827,883 and pop() throwing; the one-smaller control ends in an ordinary OutOfMemoryError at 8 GiB; exhaustive arithmetic check over every length; found the downHeap twin.
rerun on a later main still reproducesThe verifier's test on b83101cdd2 (2026-10-23): OK (6 tests).
minimal repro verifiedReduced to one test (2026-10-29); fails on main 645d4fe14a20 with assertions on and off; passes with the two-class fix.
re-verification realThe minimal test fails on main, 10.5.2 and 10.6.0; a TernaryLongHeap probe shows the same push failure and the downHeap index -2147483648.
cross-model review, refute lens (OpenAI GPT-5.6) reproduced; judged not a bugIts own test failed on main ea57360912ed with assertions on and off and it agreed on the root cause, but ruled "not real" because the heaps are @lucene.internal and no public path had been run.
Second adjudication real; public path executedIndexSearcher.search(new MatchAllDocsQuery(), Integer.MAX_VALUE) on 715,827,883 documents throws on main (on and off) and 10.6.0; the hit count at the throw, 202,045,716, matched the prediction; with n one smaller, and with the two-class fix at n, the collector collects all hits (counting runs, TopDocs not built).
history sweep introduced by af3e12265fe and ac90517c17eFails on every release run from 9.0.0 to 10.6.0 and on the introducing commit's own class; the releases before have no such class.
prior-report search no report88 phrasings, 442 hits, 409 open PRs by file, about 30 threads read; no report.
doc validation (gradle) fix A validatedRepro fails on stock main with assertions on and off and passes with fix A; with fix A the heap, collector (TestTopDocsCollector, TestSearchAfter, TestTopDocsMerge) and util.hnsw tests and core's static checks pass.

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 notes572 words

Finding record

LFV-069, snapshot of 2026-11-06

Registry title
LongHeap and TernaryLongHeap stop growing at 715,827,883 elements despite 'grows unbounded'
Area
util / heaps
Priority
Low, rank 1192: LongHeap and TernaryLongHeap stop growing at about 715M elements and are left corrupt after the throw, an unrealistic heap size, and the fix touches a hot path.
Impact class
crash
Impact note
Only heaps that reach 715,827,883 elements: IndexSearcher.search(query, n) on 10.4.0 and later with n of at least 715,827,883 on an index that large and one collector (a searcher without an executor), an HNSW search frontier of more than 715.8 million entries, or direct users of these internal heaps; released since 9.0.0 (LongHeap) and 10.4.0 (TernaryLongHeap).
Summary
You push more than 715,827,882 elements into a growable LongHeap or TernaryLongHeap. You expect the heap to keep growing, as documented. Instead (size*3+1)/2 wraps, push throws and leaves the heap corrupt; TernaryLongHeap.downHeap has a related index overflow at the same size.
Introduced
LongHeap af3e12265fe (LUCENE-9626); TernaryLongHeap ac90517c17e (#15140) (2020-12-09 (af3e12265fe); 2025-09-02 (ac90517c17e))
First affected version
9.0.0 [exec] for LongHeap (no 8.x release has the class); 10.4.0 [exec] for TernaryLongHeap and the IndexSearcher.search path
Affected versions
LongHeap: every release from 9.0.0 to 10.6.0 and main [exec on 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.2.2, 10.3.0, 10.4.0, 10.5.2, 10.6.0 and main 645d4fe14a20, the same with -ea and -da; the releases not run have the same line, read]; TernaryLongHeap and the IndexSearcher.search(query, n) path: 10.4.0 to 10.6.0 and main [exec: TernaryLongHeap probes on 10.4.0; the search on a 715,827,883-document index on main and 10.6.0]; not affected: 8.11.0 (no LongHeap) and 10.3.2 (no TernaryLongHeap) [exec]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that disputed it (the study's other checks rated it real); not yet triaged by a person.
Found by
formal: Z3
Upstream and prior reports
none found (study searches 2026-10-22; public-list check 2026-10-20..23)
Lucene commit tested
main 645d4fe14a20 (gradle in a scratch worktree and standalone, assertions on and off); the 10.6.0 and 10.5.2 jars; version-adapted variants on 9.0.0, 9.1.0, 9.12.3, 10.0.0 and 10.2.2; the earlier one-test repro on 10.3.0 and 10.4.0; the public search path on a real 715,827,883-document index on main and 10.6.0; the two-class fix on main.

Lucene and upstream sources

The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/LongHeap.java:24-25, 72, 76-84, 179-196
  • lucene/core/src/java/org/apache/lucene/util/TernaryLongHeap.java:24-25, 72, 76-84, 119-129, 151-155, 221-248
  • lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:33-34, 133-205 (oversize; clamp at :169-171), 409-414
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:588, 597-599, 618, 623-625
  • lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:44, 130-135
  • lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollectorManager.java:45-46, 62
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:588, 597-599
  • lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:44
  • lucene/core/src/java/org/apache/lucene/util/hnsw/NeighborQueue.java:60, 78; util/hnsw/HnswGraphSearcher.java:348
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:208
  • a Lucene clone tags releases/lucene/10.3.0 and 10.4.0: search/TopScoreDocCollector.java:44 (LongHeap, then TernaryLongHeap)
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:106, 226-230, 361 (one slice without an executor; a slice per segment above 250,000 docs)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/LongHeap.java:24-25, 29, 57-58, 72
  • lucene/core/src/java/org/apache/lucene/util/TernaryLongHeap.java:24-25, 29, 57-58, 72
  • lucene/core/src/test/org/apache/lucene/util/TestLongHeap.java:125-131; TestTernaryLongHeap.java:129-136
  • lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:410
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:588, 597, 618
  • build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/RenderJavadocPlugin.java:506
  • SECURITY.md:38
Suggested fix
  • lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:133-205 (oversize; clamp at :169-171)
  • SECURITY.md:46
History
  • a Lucene clone tags releases/lucene/10.3.0, 10.4.0: TopScoreDocCollector.java

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

Repro files

About the diagrams

Made in parallel with this doc from the mechanism in the brief; the PNG is the summary frame.

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.