Two int multiplications by 3 in these heaps overflow at the same size, 715,827,883.
LongHeap(two children per node) andTernaryLongHeap(three) are min-heaps oflongvalues kept in one array, 1-based: slot 0 is unused and the smallest value sits in slot 1.- In
TernaryLongHeapthe 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. pushappends 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)".- Since 10.4.0
IndexSearcher.search(query, n)keeps the top n hits in aTernaryLongHeapof n slots filled with placeholders; every better hit replaces the top value and sinks down.
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.- Fill a
LongHeap(orTernaryLongHeap) with 715,827,882 values, for example withnew LongHeap(715_827_882, 0L). Its array has 715,827,883 slots (slot 0 is unused), 5.73 GB, and is now full. - Call
push(1). The javadoc sayspush"Grows unbounded as needed", so you expect the array to grow to about 1.2 billion slots, or anOutOfMemoryErrorif the JVM heap is too small. - Instead the new length
(size * 3 + 1) / 2is computed inint: 715,827,883 x 3 + 1 = 2,147,483,650 does not fit and wraps, giving -1,073,741,823.pushthrows (AssertionErrorwith assertions on,ArrayIndexOutOfBoundsException: Index 715827883with them off), andsize()already counts the lost value. - 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 executorsearcher.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 theTopDocs).
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.
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) assertsminSize >= 0with the message "size must be positive (got -1073741823): likely integer overflow?" (:410). Without assertions a negativeminSizeis 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 throwsize()says 715,827,883 although only 715,827,882 values are stored; a laterpop()throwsArrayIndexOutOfBoundsException(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 - 1fails. A heap grown only bypushgets 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 realArrayUtil.oversizefor every capacity from 1 to 2,000,000).
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 -2147483648Only 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).
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)usesTernaryLongHeapfrom 10.4.0 on (LongHeapin 10.3.x,HitQueuebefore). Lucene itself caps n atmaxDoc, soInteger.MAX_VALUEreaches 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), thensearcher.search(new MatchAllDocsQuery(), Integer.MAX_VALUE)onnew IndexSearcher(reader)(no executor) with an 8 GB JVM heap:ArrayIndexOutOfBoundsException: Index -2147483648 out of bounds for length 715827884after 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 countingCollectorManagerthat does not buildTopDocs(oneScoreDocper hit would need far more than 8 GB). - Other reach (read, not run).
NeighborQueuepushes the HNSW search frontier into aTernaryLongHeap; 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-codecsPForUtilcopies) use 8 slots and never grow. External code may use these@lucene.internalheaps 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 millionScoreDocs 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
pushthrows; nothing is written. A heap whosepushthrew is left counting one value too many, so code that catches the exception and keeps using the heap reads past its end.
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.
/*
* 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());
}
}
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) andsize()is 715,827,883, orOutOfMemoryErrorif the heap is too small.- Observed
AssertionError: size must be positive (got -1073741823): likely integer overflow?fromArrayUtil.growwith assertions on;ArrayIndexOutOfBoundsException: Index 715827883 out of bounds for length 715827883with them off.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the same test fails on 10.6.0 and 10.5.2 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64; a 10 GB test heap |
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
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).- 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.growcalls 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-25andTernaryLongHeap.java:24-25;pushjavadoc:72in both. The constructors acceptinitialCapacityin [1,MAX_ARRAY_LENGTH - 1] and throw early "to prevent confusing OOME" (:57-58). No size limit belowMAX_ARRAY_LENGTHis documented anywhere. - Tests.
TestLongHeap.testUnboundedandTestTernaryLongHeap.testUnboundedassert growth past the initial capacity (at small sizes);testInvalidrejects only -1, 0 andMAX_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
maxSizeparameter"; the author replied that changing it "could break existing code that relies on unbounded growth. For instance,NeighborQueue", and the parameter was renamedinitialCapacity. 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.
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.downHeapis 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 - 1values still cannot take one more (pushthen throwsArrayIndexOutOfBoundsException); that is the real array limit, and a clearer exception there is optional. size++still runs before the growth, so anOutOfMemoryErrorfromgrowalso leavessize()one too high; moving it after the store is optional hardening.- Open draft PR #16847 puts a new
DocScoreHeapintoTopScoreDocCollector: itsPackedDocScoreHeapstill wraps aTernaryLongHeap, and itsLongDocScoreHeaprepeatsint firstChild = ARITY * (i - 1) + 2; the same change would apply there. Open draft PR #12314 also editsLongHeap.push. - Tests: the one-test repro covers
pushonly, not theTernaryLongHeap.downHeapline that the public search reaches (node 715,827,883 of a full heap). TheLongHeap.downHeapchange fixes a separate wrap (i << 1from node 2^30, an 8.6 GB array); a narrower change could touch only bothpushmethods andTernaryLongHeap.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 theorg.apache.lucene.util.hnswtest package (10 classes, includingTestNeighborQueueand the HNSW graph tests): 0 failures. ./gradlew :lucene:core:check -x testwith 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,TopDocsnot built).
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).
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.
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).
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.
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.
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
intas 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.downHeaptwin. Later: a rerun on mainb83101cdd2, 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).
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues and PRs (incl. migrated JIRA); Elasticsearch, OpenSearch, OpenSearch k-NN, Solr | 2026-11-01 | 88 GraphQL phrasings: class names with overflow, grow, push, downHeap, firstChild, ArrayIndexOutOfBoundsException; the expressions; the numbers 715827883, 715827884, 1073741823; the exception messages; the javadoc phrases | 189 distinct items, none this bug |
| GitHub, commits, downstream trackers, mailing lists, JIRA archive, web | 2026-11-01 | a 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 read | 442 distinct hits, none this bug |
| Open PRs | 2026-11-01 | all 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 full | 2026-11-01 | #15140, #15163, #16153, #14714, #10885, #11420, #615, #623, LUCENE-9626, #12255, #16847, #12314 and about 18 more | no discussion of int overflow in these heaps |
| Earlier searches | 2026-10-22 and 2026-11-01 | the study's 24 issue/PR phrasings; a scan of every issue and PR updated since 2026-10-20 | no report |
| Upstream main and branches | 2026-11-01 | blob compare of main f815e679bf6c, branch_11x and branch_10_6; CHANGES.txt; git log -S | not fixed |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal 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 | affected | minimal repro fails (on and off); the search on an index written by 10.6.0 throws the same exception |
| 10.5.2 | affected | minimal repro fails with assertions on and off |
| 10.4.0 | first TernaryLongHeap | TernaryLongHeap 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.0 | version-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 commit | its LongHeap.java, compiled over 9.0.0, fails at the same boundary |
| 8.11.0; 10.3.2 | not affected | no LongHeap class; no TernaryLongHeap class (compile error) |
upstream main f815e679bf6c, branch_10_6 | read | the same two lines (source read, not executed) |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder: CodeQL + Z3 | first wrapping length 715,827,883 | 603-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) | reproduced | Its 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 reproduces | The verifier's test on b83101cdd2 (2026-10-23): OK (6 tests). |
| minimal repro | verified | Reduced to one test (2026-10-29); fails on main 645d4fe14a20 with assertions on and off; passes with the two-class fix. |
| re-verification | real | The 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 bug | Its 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 executed | IndexSearcher.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 ac90517c17e | Fails 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 report | 88 phrasings, 442 hits, 409 open PRs by file, about 30 threads read; no report. |
| doc validation (gradle) | fix A validated | Repro 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. |
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 445 KB
Finding record, Lucene and upstream sources, repro files and diagram notes572 words
- 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.
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/util/LongHeap.java:24-25, 72, 76-84, 179-196lucene/core/src/java/org/apache/lucene/util/TernaryLongHeap.java:24-25, 72, 76-84, 119-129, 151-155, 221-248lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:33-34, 133-205 (oversize; clamp at :169-171), 409-414lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:588, 597-599, 618, 623-625lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:44, 130-135lucene/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-599lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:44lucene/core/src/java/org/apache/lucene/util/hnsw/NeighborQueue.java:60, 78; util/hnsw/HnswGraphSearcher.java:348lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:208a 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, 72lucene/core/src/java/org/apache/lucene/util/TernaryLongHeap.java:24-25, 29, 57-58, 72lucene/core/src/test/org/apache/lucene/util/TestLongHeap.java:125-131; TestTernaryLongHeap.java:129-136lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:410lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:588, 597, 618build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/RenderJavadocPlugin.java:506SECURITY.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
- TestLongHeapGrowthOverflow.java (JUnit, one test):
repro/LFV-069/TestLongHeapGrowthOverflow.java(31 lines)
Made in parallel with this doc from the mechanism in the brief; the PNG is the summary frame.
assets/LFV-069/bug.png(445 KB)
