IntroSorteris Lucene's general-purpose in-place sort, behindArrayUtil.introSortandCollectionUtil.introSort. It sorts the slotsfrom(inclusive) toto(exclusive) throughcompare,setPivot/comparePivotandswapcallbacks.- It runs quicksort, but some orders make quicksort pick bad pivots again and again. After 2 x log2(n) levels it stops and heap-sorts the part that is left, which is always O(n log n).
- Heap sort views the slots from
starton as a binary tree: the node at offset k has its children at offsets 2k + 1 and 2k + 2. A Javaintholds at most 2,147,483,647.
int range: a leaf's child index 2k + 1 passes 2,147,483,647, wraps to a negative number and slips past the child < to check, so the sorter is asked for a slot outside the slice. Select the still to open it at full size. Generated with AI, checked against the code.- You sort the last 60 slots of a maximum-length array (
ArrayUtil.MAX_ARRAY_LENGTH= 2,147,483,631), slots 2,147,483,571 to 2,147,483,630, withArrayUtil.introSortor your ownIntroSorter, and the values come in an order that defeats quicksort's pivot choice. - You expect a sorted slice, with only those 60 slots read or swapped.
- After 2 x log2(60) = 10 quicksort levels,
IntroSorterheap-sorts the last 27 slots, from 2,147,483,604. Heap sort puts a node's child at start + 2k + 1, inint: for the last slot (k = 26) that is 2,147,483,657, past theintmaximum 2,147,483,647, so it wraps to -2,147,483,639. - The check
child < toaccepts the negative number, so the sorter is asked for slot -2,147,483,639. On a real array of that lengthArrayUtil.introSortthrowsArrayIndexOutOfBoundsException: Index -2147482757 out of bounds for length 2147483631(with a 1,000-value adversarial tail). - Any slice with 2*to - from > 2^31 can hit it, for example more than 2^30 (1,073,741,824) elements from index 0, but only in an order that uses up the quicksort budget: random data in the same slice did not reach heap sort in the recorded runs.
Code that calls ArrayUtil.introSort, CollectionUtil.introSort or its own IntroSorter on a slice of more than 2^30 elements, or one ending near the maximum array length, in an order that defeats quicksort. No setting is involved and assertions make no difference. No in-tree Lucene path was shown to reach it. It fails loudly, never as a silent mis-sort.
Mechanism, terms and numbers666 words
IntroSorter (behind ArrayUtil.introSort and CollectionUtil.introSort) runs quicksort and falls back to Sorter.heapSort(from, to) once its depth budget of 2 * log2(n) levels is used up. Heap sort's siftDown finds a node's left child with heapChild(from, i) = ((i - from) << 1) + 1 + from, computed in int, and loops while leftChild < to. For a node with no child the exact index can be 2^31 or more; it then wraps to a negative int, passes leftChild < to, and compare(i, leftChild) runs with that negative slot. For array-valid slots (0 <= from <= to) this can happen exactly when 2*to - from > 2^31: more than 2^30 = 1,073,741,824 elements from index 0, or a slice that ends near ArrayUtil.MAX_ARRAY_LENGTH. It also needs an element order that uses up the quicksort budget; the recorded random controls did not. Array-backed sorts throw ArrayIndexOutOfBoundsException at the first out-of-slice compare, before any out-of-slice swap; a custom Sorter subclass is handed the negative slot. It is never a silent mis-sort through Lucene's array sorters. The code dates from LUCENE-4946 (4.4.0, 2013), and an overflow-safe loop guard fixes it. No in-tree Lucene caller was shown to reach it.
IntroSorter.sort(from, to)callssort(from, to, 2 * MathUtil.log(to - from, 2))(IntroSorter.java:44). When--maxDepth < 0it callsheapSort(from, to)on the sub-range left at that point (:59-61, "Max recursion depth exceeded: fallback to heap sort").heapSort(Sorter.java:259-268) builds the heap withheapify(:270-274), then sifts each extracted element down.siftDownloopsfor (int leftChild = heapChild(from, i); leftChild < to; leftChild = heapChild(from, i))(:277) and callscompare(i, leftChild)first (:279).heapChild(from, i)is((i - from) << 1) + 1 + frominint(:300-302). The largest node,to - 1, gets the exact child2*to - from - 1, so some node's child index reaches 2^31 exactly when2*to - from > 2^31(a Z3 proof). For array-valid slots (0 <= from <= i < to <= Integer.MAX_VALUE, the model's precondition;checkRangerejects onlyto < from) the exact value is below 2^32, so the wrappedintis always negative, and every negative value passesleftChild < to. WhethersiftDownreaches such a leaf depends on the data.ArrayIntroSorter.comparereadsarr[j](ArrayIntroSorter.java:40-42) and throwsArrayIndexOutOfBoundsException; the defaultIntroSorter.comparecallssetPivot(i)andcomparePivot(j)(IntroSorter.java:155-158), so a custom sorter is handed the negative slot. This happens before any out-of-slice swap; earlier in-slice swaps have run, so the slice is left partly reordered.- On this path the other index arithmetic is overflow-safe:
heapParentuses>>> 1(Sorter.java:296-298; Z3 Q2 UNSAT) and IntroSorter's pivot-sampling indices, including the midpoint(from + last) >>> 1(IntroSorter.java:67), stay in the slice (Q3, Q3b UNSAT). Here onlyheapChild's<< 1wraps. Not all of the sorters' index arithmetic is safe: the same Z3 sweep found thatSorter.upper2'st += delta << 1(Sorter.java:173, used only by TimSorter's merge) can wrap (Q4 SAT, not executed), andinsertionSort'sfrom + 1(Sorter.java:241) wraps for the empty range[Integer.MAX_VALUE, Integer.MAX_VALUE). Both are separate sites, not part of this bug.
The minimal test sorts 60 values at slots [2147483571, 2147483631), the last 60 slots of an array of length MAX_ARRAY_LENGTH = 2,147,483,631 (16-byte array header). A trace of the same input shows heapSort(2147483604, 2147483631) (27 slots); heapify walks a value down to the last slot, 2,147,483,630 (offset 26, a leaf), and heapChild gives 2,147,483,604 + 53 = 2,147,483,657, which wraps to -2,147,483,639. With a 24-byte header (-XX:-UseCompressedOops) the slice is [2147483563, 2147483623) and the bad slot -2,147,483,647. On a real Integer[2147483631] with a 1,000-value adversarial tail, ArrayUtil.introSort throws Index -2147482757 out of bounds for length 2147483631. Random values in the same slice sort without reaching heap sort.
Exposure and likelihood188 words
- Who is hit. Application code that sorts with
ArrayUtil.introSort(ArrayUtil.java:509-520),CollectionUtil.introSort(CollectionUtil.java:133, by reading) or its ownIntroSortersubclass. These classes are@lucene.internalbut public, so downstream code can call them. - Default vs opt-in. The default code path of the sorter; no setting.
-eaand-dabehave the same. - Scale. Both (a)
2*to - from > 2^31(more than 2^30 elements from 0, or a slice ending nearMAX_ARRAY_LENGTH, about 8 GiB of references) and (b) an order that exhausts the2*log2(n)budget (about 60 levels for a billion elements). The from-0 case was shown only by callingheapSortdirectly on virtual data; it would need an adversarial input of about 10^9 elements end to end. - Lucene's own size domain.
DocValuesFieldUpdatessorts up toInteger.MAX_VALUEbuffered entries with anIntroSorter(index/DocValuesFieldUpdates.java:297-337,:376-378), so sizes above 2^30 are in scope for IntroSorter; reaching the fallback there was not executed and is not claimed. - Effect. One sort ends with an exception (or a custom sorter sees a negative slot). No hang, no wrong results through Lucene's array sorters, no index data touched.
One JUnit test on IntroSorter's public sort and protected hooks: 60 fixed values for the last 60 slots of a maximum-length range, kept in a 60-element array, so no big heap is needed. Its slot check fails the test as soon as a slot outside the slice is requested. Deterministic; the test itself takes 0.02 s.
/*
* 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 TestIntroSorterHeapSortAtArrayEnd extends LuceneTestCase {
public void testHeapSortFallbackStaysInSlice() {
int[] values = {
18, 55, 11, 58, 6, 15, 32, 0, 10, 21, 48, 7, 39, 16, 1, 53, 49, 23, 52, 17,
51, 37, 46, 26, 42, 41, 40, 30, 38, 2, 36, 8, 34, 12, 56, 54, 3, 50, 9, 13,
44, 19, 25, 27, 29, 31, 45, 59, 43, 22, 24, 28, 4, 14, 20, 57, 35, 47, 33, 5
};
int to = ArrayUtil.MAX_ARRAY_LENGTH, from = to - values.length;
new IntroSorter() {
int pivot;
int value(int slot) {
// expected: only slots in [from, to); actual: the heapSort fallback asks for slot < 0
if (slot < from || slot >= to) {
throw new AssertionError("slot " + slot + " is outside [" + from + ", " + to + ")");
}
return values[slot - from];
}
@Override
protected void setPivot(int i) {
pivot = value(i);
}
@Override
protected int comparePivot(int j) {
return Integer.compare(pivot, value(j));
}
@Override
protected void swap(int i, int j) {
int tmp = value(i);
values[i - from] = value(j);
values[j - from] = tmp;
}
}.sort(from, to);
}
}
cp TestIntroSorterHeapSortAtArrayEnd.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestIntroSorterHeapSortAtArrayEnd -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
- Expected
- Every slot passed to
setPivot,comparePivotandswaplies in[2147483571, 2147483631). - Observed
AssertionError: slot -2147483639 is outside [2147483571, 2147483631), fromSorter.siftDownin the heap-sort fallback, with assertions on and off.
More files, environment, full output360 words
The 60 values are a fixed permutation of 0..59, computed once with McIlroy's quicksort adversary ("A Killer Adversary for Quicksort", 1999) run through IntroSorter at slots [0, 60), so the sort uses up its depth budget; frozen, they replay the same comparisons with a plain int comparator. 60 is the smallest size of such an adversary input (sizes 17 to 300 were searched) that fails with both a 16- and a 24-byte array header; the simple orders tried (ascending, descending, organ pipe, sawtooth and others) produced no slot outside the slice at this position up to n = 4,096. The test asserts only that no slot outside [from, to) is requested, not sortedness. The slice ends at MAX_ARRAY_LENGTH, not Integer.MAX_VALUE, so the arithmetic is exactly that of ArrayUtil.introSort(a, from, to) on a real maximum-length array; the same failure on such a real array needs about 12 GB of heap and is evidence, not the proposed test.
As the regression test: the same test, for example as a new method in TestIntroSorter.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0, 10.5.2 and 9.12.3 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp TestIntroSorterHeapSortAtArrayEnd.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestIntroSorterHeapSortAtArrayEnd -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
# gradle, main 645d4fe14a20, seed DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestIntroSorterHeapSortAtArrayEnd > testHeapSortFallbackStaysInSlice FAILED
java.lang.AssertionError: slot -2147483639 is outside [2147483571, 2147483631)
at __randomizedtesting.SeedInfo.seed([DEADBEEF:9A0DA62F0AC7FEFD]:0)
at org.apache.lucene.util.TestIntroSorterHeapSortAtArrayEnd$1.value(TestIntroSorterHeapSortAtArrayEnd.java:36)
at org.apache.lucene.util.TestIntroSorterHeapSortAtArrayEnd$1.comparePivot(TestIntroSorterHeapSortAtArrayEnd.java:48)
at org.apache.lucene.util.IntroSorter.compare(IntroSorter.java:157)
at org.apache.lucene.util.Sorter.siftDown(Sorter.java:279)
at org.apache.lucene.util.Sorter.heapify(Sorter.java:272)
at org.apache.lucene.util.Sorter.heapSort(Sorter.java:263)
at org.apache.lucene.util.IntroSorter.sort(IntroSorter.java:61)
at org.apache.lucene.util.IntroSorter.sort(IntroSorter.java:44)
at org.apache.lucene.util.TestIntroSorterHeapSortAtArrayEnd.testHeapSortFallbackStaysInSlice(TestIntroSorterHeapSortAtArrayEnd.java:57)
# scratch trace of the same input (abridged): the heap-sort sub-range and the wrapped child
heapSort(2147483604, 2147483631) size=27
compare(i=2147483630, leftChild=-2147483639) exact child = 2147483604 + 2*26 + 1 = 2147483657 > 2147483647
# -XX:-UseCompressedOops (24-byte array header, MAX_ARRAY_LENGTH = 2147483623)
java.lang.AssertionError: slot -2147483647 is outside [2147483563, 2147483623)
# ArrayUtil.introSort on a real Integer[2147483631], adversarial last 1,000 values (-Xmx12g, -da; main ea57360912ed and 645d4fe14a20, and 10.5.2)
java.lang.ArrayIndexOutOfBoundsException: Index -2147482757 out of bounds for length 2147483631
at org.apache.lucene.util.ArrayIntroSorter.compare(ArrayIntroSorter.java:41)
at org.apache.lucene.util.Sorter.siftDown(Sorter.java:279)
...
at org.apache.lucene.util.ArrayUtil.introSort(ArrayUtil.java:519) (ArrayUtil.java:520 on 10.5.2)
# with either fix: the test passes (assertions on and off for the loop guard)Sorter.sortandArrayUtil.introSortpromise to sort the slice[from, to), reject onlyto < from, and this slice is withinMAX_ARRAY_LENGTH.- The fallback is meant for adversarial orders: it "prevents quicksort from running into its worst-case quadratic runtime".
- The neighbouring index arithmetic is overflow-safe (
>>> 1); the 2013 "no real issue" overflow remark was about the end index, notheapChild.
The full argument232 words
- Contract.
Sorter.sortsorts "the slice which starts at from (inclusive) and ends at to (exclusive)" (Sorter.java:75-79);heapSortsorts "items between from inclusive and to exclusive" and "is used as a fall-back by IntroSorter" (:254-258);checkRangerejects onlyto < from(:81-86).ArrayUtil.introSort"Sorts the given array slice" with no size limit (ArrayUtil.java:509-520);MAX_ARRAY_LENGTHis the documented maximum (:32-34). No javadoc,CHANGES.txt,MIGRATE.mdtext or test sets a 2^30 limit. - Tests. Upstream tests use small offsets and lengths, so the heap path at large indices is untested, not specified.
TestArrayUtil.testQuickToHeapSortFallback(TestArrayUtil.java:168-178) is named for the fallback (its comment dates it to LUCENE-3054 and the old merge-sort fallback), but its input,createSparseRandomArray(40000)(:159-166), holds only 0s and 1s and does not reachheapSorton main: a fact-check probe counted 0 of 6,000 such arrays reaching it. - Purpose of the fallback. The IntroSorter javadoc (
IntroSorter.java:19-25), and the LUCENE-7097 description (#8152, by Mike McCandless): introsort "detects when an adversary is at work and cuts over to heapsort". - Precedent. LUCENE-6293 (#7355), an AIOOBE in TimSorter on a huge, specially patterned input, was fixed although its author (Adrien Grand) called it "rather unlikely to happen in practice". In a 2019 IntroSelector review (lucene-solr PR #966) he wrote that index additions "can overflow on large arrays ... This is a famous bug".
The devil's-advocate check233 words
The strongest case (an independent reviewer from another model family, OpenAI GPT-5.6; the intended-behaviour checker; the adjudication): every class involved is @lucene.internal ("APIs in this package are primarily internal and may change between releases"); the first test called the package-private heapSort; the trigger is extreme in size and adversarial in order; no in-tree caller reaches it; and in the 2013 LUCENE-4946 review (#6010) Uwe Schindler wrote "The overflow issue is no real issue as the meximum array size is limited, too" (sic).
Why it fails: @lucene.internal marks API stability, not a size cap or a correctness waiver; the minimal test uses only the public sort and protected hooks, and ArrayUtil.introSort fails on a real legal array; the 2013 remark answered Dawid Weiss's point that an exclusive end index "would overflow into negative values" when sorting a full array, the to bound at 2^31, while heapChild already wraps from 2^30 elements on and was never discussed; and adversarial orders are what the fallback is for. These limit severity, not validity, and they do limit it: the trigger is remote (an array of about 8 GiB of references with a 12 GiB heap, or about 10^9 elements, in an order built to defeat quicksort), and @lucene.internal supports a low priority. The cross-model reviewer rated the works-as-designed case weak; the fact check's cross-model reviewer rated it moderate: enough for low priority, not for "by design".
Recommended Loop in siftDown only while a left child exists
In siftDown, loop only while a left child exists, while (i - from < (to - from) >>> 1), and compute heapChild inside the loop: this check cannot overflow and the comparisons stay the same (java.util.PriorityQueue uses this guard). With it the repro passes with assertions on and off, and the core sorter tests pass.
void siftDown(int i, int from, int to) {
- for (int leftChild = heapChild(from, i); leftChild < to; leftChild = heapChild(from, i)) {
+ // Loop while i has a left child. Unlike heapChild(from, i) < to, this test cannot overflow.
+ while (i - from < (to - from) >>> 1) {
+ final int leftChild = heapChild(from, i);
final int rightChild = leftChild + 1;All options and trade-offs3 options ยท 580 words
Two equivalent fixes; both were run through gradle in a scratch worktree of 645d4fe14a20.
With k = i - from and n = to - from, a left child exists iff 2k + 1 < n iff k < floor(n / 2). Inside the loop heapChild is then at most to - 1 and rightChild at most to, so neither can overflow. It is the guard java.util.PriorityQueue uses (int half = n >>> 1; // loop while a non-leaf, while (k < half)).
void siftDown(int i, int from, int to) {
- for (int leftChild = heapChild(from, i); leftChild < to; leftChild = heapChild(from, i)) {
+ // Loop while i has a left child. Unlike heapChild(from, i) < to, this test cannot overflow.
+ while (i - from < (to - from) >>> 1) {
+ final int leftChild = heapChild(from, i);
final int rightChild = leftChild + 1;Trade-offs
No API or format change, and the same comparisons and swaps as today for every slice that does not overflow. The loop test compares i - from with (to - from) >>> 1 (loop-invariant) instead of comparing heapChild(from, i) with to; heapChild is still computed inside the loop, so each step adds one subtraction. Performance was not measured, and this is the cold fallback path.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20: without the fix the repro fails with assertions on and off. - With the fix it passes with assertions on and off (
-Ptests.asserts=false). - Closest suites, the core util sorter and selector tests (
TestIntroSorter,TestArrayUtil,TestCollectionUtil,TestInPlaceMergeSorter,TestTimSorter,TestMSBRadixSorter,TestStableMSBRadixSorter,TestStringSorter,TestIntroSelector): 9 classes, 74 tests, 0 failures. ./gradlew :lucene:core:check -x test(google-java-format, ecj lint, forbidden APIs) passes with the repro test in place. The full test suite was not run.- Earlier, the same guard written as a
forloop, shadowed into the stock jars, passed the finder's test, a separate public-API probe and the minimal test with assertions on and off.
Keeps siftDown as is.
static int heapChild(int from, int i) {
- return ((i - from) << 1) + 1 + from;
+ // The exact index can exceed Integer.MAX_VALUE; saturate, since any value >= to means no child.
+ return (int) Math.min(((long) (i - from) << 1) + 1 + from, Integer.MAX_VALUE);
}Trade-offs
Correct because to <= Integer.MAX_VALUE, so a saturated value always fails leftChild < to. One long shift and a min per step; slightly less direct than the loop guard.
What the checks validated
Gradle in the same scratch worktree: the repro, TestIntroSorter and TestArrayUtil pass with assertions on (3 classes, 29 tests, 0 failures). With assertions off, the minimal test passes with this fix standalone (fact check). The other suites and the static checks were not run for this option. The independent reproducer validated a close variant (the loop computing the child in long) on its 11 tests and on the real Integer[MAX_ARRAY_LENGTH] run.
Reject slices with 2*to - from > 2^31 in checkRange, or document the limit.
Trade-offs
Rejects legal slices that sort fine with ordinary data; Math.multiplyExact would likewise turn the bad slot into an ArithmeticException and still fail a legal sort.
Downstream code that sorts more than 2^30 (about 1.07 billion) elements, or a slice at the top of a maximum-length array, in an order that defeats quicksort, with ArrayUtil.introSort or its own IntroSorter, can hit this exception until it is fixed. A custom Sorter subclass is handed a negative slot; what it then does is up to that subclass.
Formal: an SMT solver (Z3) checked the sorters' int index arithmetic with exact Java wrap-around. It found that heapChild can wrap inside siftDown and proved the exact condition, 2*to - from > 2^31, and that to <= 2^30 is safe. A JUnit on the stock build then witnessed it, and independent checks reached it through the public API.
The method in full177 words
Method: formal (SMT solving). In the hunt unit on sorters and selectors, the finder modelled the int index arithmetic of Sorter, IntroSorter and their helpers as 32-bit bit-vectors with exact Java wrap-around and asked Z3: Q1 (can heapChild wrap inside siftDown for a legal slice?) SAT; Q1b, the minimal to for from = 0: 1,073,741,825; Q1c, a 2-slot slice at the top of the range; Q1d, the exact condition 2*to - from > 2^31 (negation UNSAT); Q1e, safe for to <= 2^30 (UNSAT). heapParent and IntroSorter's pivot-sampling indices were proved to stay in range (Q2, Q3, Q3b UNSAT); the same sweep found a possible wrap in TimSorter's Sorter.upper2 (Q4 SAT; not executed; a separate site). The unit was chosen by area (the sorters' index arithmetic), not with this bug in mind. A same-package JUnit that calls heapSort at slots [MAX - 1000, MAX) then failed on the stock build; an independent reproducer built the public-API path and the real-array run with McIlroy's adversary, and the test was later cut to the 60-value one shown here.
No prior report found in GitHub issues and PRs (including the migrated JIRA issues), commits, open PRs, the Lucene mailing lists, downstream trackers or the web (2026-10-26); not fixed upstream. The 2013 review of the introducing change discussed a different overflow.
Searches4 searches
Closest items, none a report of this bug: #6010 (LUCENE-4946, the introducing issue; its overflow remark concerns the exclusive end index at 2^31); #7355 (LUCENE-6293, a fixed TimSorter AIOOBE at extreme scale, precedent); #8152 (LUCENE-7097, the depth budget, performance only); #9866 (LUCENE-8822, open, an UnsupportedOperationException from MSBRadixSorter.compare under heapify/siftDown, a different symptom); #11477 (LUCENE-10441, open, an AIOOBE in BytesRefHash.get through binarySort); open PR #12800 (makes binarySort protected; no heap code). Limits: GitHub search does not index code or diffs; private reports are not visible; searches cannot prove that no report exists, and #6010 is related discussion, not an identified duplicate.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues and PRs (incl. the migrated JIRA issues), commits, downstream repositories | 2026-10-26 | a scripted prior-report search (13 issue/PR, 3 commit and 2 downstream queries), 56 direct GitHub searches over apache/lucene, apache/lucene-solr, apache/lucenenet, apache/solr, Elasticsearch, OpenSearch and org:apache, and the verifiers' 74 queries | no report; 23 hits read, none the same bug |
| Open PRs | 2026-10-26 | changed files of all 411 open apache/lucene PRs; diffs of the 5 that touch sorter files | none changes the heap code |
| JIRA archive, mailing lists, web | 2026-10-26 | 10 JIRA-archive queries; 32 queries over dev@, java-user@, issues@ and commits@; 6 web queries | no report |
| Commit history | 2026-10-26 | git log --all -S heapChild in the history clone; upstream main e1c4184cb983 (2026-10-26), rechecked at 2f424fd428da (2026-10-30) | the two LUCENE-4946 commits and a 2021 commit that retired the branch_8x tree; heapChild and siftDown unchanged since 2013 |
Present since Sorter and IntroSorter were created by LUCENE-4946 (b895ebde4340, 2013-05-03; first release 4.4.0). Every release from 4.4.0 to 10.6.0 and main is affected; 14 versions were executed. Not a regression.
Versions tested8 rows
Introduced by LUCENE-4946 "Refactor SorterTemplate (now Sorter)" (Adrien Grand; now issue #6010; no GitHub PR): trunk commit b895ebde4340 (svn r1478785) and the branch_4x backport fd331e9d5f6b (svn r1478802), both 2013-05-03. It created heapSort/heapify/siftDown and heapChild in int, and made heap sort IntroSorter's fallback; the predecessor SorterTemplate fell back to merge sort with overflow-safe midpoints. LUCENE-7097 (6.1.0) and LUCENE-10196 (8.11.0) changed the depth budget and partitioning but kept the fallback; the heap lines are byte-identical from 4.4.0 to main. All 116 release tags from 4.4.0 on (to 10.6.0) contain the commit or its backport, with byte-identical heapChild and loop-guard lines. Not run one by one: 8.8.2 to 8.11.3.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails with assertions on and off (standalone and gradle); passes with either fix |
| 10.6.0 | affected | minimal test fails with assertions on and off |
| 10.5.2 | affected | minimal test fails; ArrayUtil.introSort on a real Integer[MAX_ARRAY_LENGTH] throws AIOOBE |
| 9.12.3 | affected | minimal test fails with assertions on and off |
| 9.1.0 | affected | minimal test fails (assertions off; fact check) |
| 4.10.4, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 10.0.0 | affected | the hunt's direct heapSort port and public-API port (an IntroSorter subclass in another package) both fail |
| 4.4.0 | first affected | both ports fail with assertions on and off; the direct port at the same slot as main |
| 4.3.1 | no such API | no Sorter/IntroSorter; the SorterTemplate analog stays inside the slice |
Confirmed with high confidence: an independent reproducer, an intended-behaviour devil's advocate and an adjudication all reproduced it, and a reviewer from another model family (OpenAI GPT-5.6) rated the not-a-bug case weak. Nobody found it intended; all agree it is a rare, loud failure.
Checks and verdicts271 words
Re-run at registry time, in a credibility audit, on a later main and after minimisation: still reproduces.
| Check | Verdict | What it found |
|---|---|---|
| finder (Z3 + JUnit) | candidate | Z3 found and characterised the wrap; a same-package JUnit fails on main (assertions on and off), 10.5.2 and 9.12.3; its slot-0 control passes; the fix passes. |
| cross-model review (OpenAI GPT-5.6, refute lens) | no verdict | 3 attempts gave no verdict; not a refutation. |
| independent reproducer | real | Own tests fail 6 of 11 on main, 10.5.2 and 9.12.3, all 5 controls pass; public-API path and the real Integer[MAX_ARRAY_LENGTH] AIOOBE on main and 10.5.2; a long-arithmetic fix passes. |
| cross-model adversary (OpenAI GPT-5.6, intended lens, reading only) | real, medium | Rated the works-as-designed case weak; a separate public-API probe fails on main and 10.5.2 and passes with the fix. |
| intended-behaviour devil's advocate | real, high | No not-a-bug argument survived; re-ran the repros on main, 10.5.2 and 9.12.3. |
| adjudication | confirmed, high | Re-ran the finder repro (assertions on and off) and the public test; class crash. |
| audit, refresh and minimisation | confirmed | Credibility audit kept it; still reproduces on main a9779ef3c193; minimised to the 60-value test, which fails on 645d4fe14a20 and passes with the fix. |
| fact check (2026-11-01) | real; wording fixed | One reviewer re-ran the minimal test (standalone and gradle, assertions on and off), both fixes, 10.6.0, 9.12.3, 9.1.0, the real-array run on 645d4fe14a20 and the Z3 model; a reviewer from another model family (OpenAI GPT-5.6) read the evidence without running code. Both confirmed the bug; the wording errors and overclaims they found are fixed in this doc. |
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 474 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,420 words
- Registry title
- Sorter.heapChild overflows int, so IntroSorter's heap-sort fallback compares a negative slot outside [from, to) when 2*to - from > 2^31 (ArrayIndexOutOfBoundsException from ArrayUtil.introSort on a legal array slice)
- Area
- util / sorters (Sorter heap sort, IntroSorter's depth fallback)
- Priority
- Low, rank 1064: An internal sorter's heap-sort fallback throws only on slices of more than 2^30 elements with adversarial ordering, a practically unreachable loud overflow.
- Impact class
- crash
- Impact note
- Only sorts whose slice satisfies 2*to - from > 2^31 (more than 2^30 = 1,073,741,824 slots starting at 0, or a slice that ends near Integer.MAX_VALUE or ArrayUtil.MAX_ARRAY_LENGTH) and whose element order uses up IntroSorter's 2*log2(n) quicksort depth budget so that the heap-sort fallback runs (executed with a quicksort-adversary order; random data in the same slice never reached the fallback), through the @lucene.internal IntroSorter or ArrayUtil.introSort called by application code [exec] (CollectionUtil.introSort by reading); no in-tree Lucene caller was shown to reach it; array-backed sorts always throw at the first out-of-slice compare, before any out-of-slice swap, while a custom Sorter subclass is handed a negative slot; no setting is needed, -ea and -da behave the same, and every release from 4.4.0 to 10.6.0 and main is affected.
- Summary
- You sort a legal slice with IntroSorter, for example ArrayUtil.introSort on the last 1,000 elements of an array of length ArrayUtil.MAX_ARRAY_LENGTH (or a slice of more than 2^30 elements starting at 0), and the element order makes IntroSorter give up on quicksort and fall back to heap sort. You expect the slice [from, to) to be sorted using only slots inside it. Instead Sorter.heapChild computes 2*(i - from) + 1 + from in int, which wraps to a negative number for a node that has no child, siftDown's 'leftChild < to' check lets it through, and compare is called with a negative slot, so the array-backed sort throws ArrayIndexOutOfBoundsException ('Index -2147482757 out of bounds for length 2147483631').
- Introduced
- b895ebde4340 (LUCENE-4946 'Refactor SorterTemplate (now Sorter)', by GitHub jpountz, svn r1478785 on trunk; branch_4x backport fd331e9d5f6b, svn r1478802, which the 4.x releases contain; committed from JIRA patches, no GitHub PR; the issue is now #6010) [read: git log -L and git blame on heapChild, siftDown and heapSort list only this commit; it created Sorter with heapSort/heapify/siftDown and heapChild in int arithmetic and made heap sort IntroSorter's depth fallback, while the predecessor SorterTemplate fell back to merge sort with overflow-safe midpoints] [exec: 4.3.1 has no Sorter or IntroSorter (compile error) and its SorterTemplate analog stays inside the slice at the same slots; 4.4.0 fails at the same slot as main]. Not a regression: LUCENE-7097 (8cbe4713775) and LUCENE-10196 (63b9e603e6f) changed IntroSorter's depth budget and partitioning but kept the heap-sort fallback, and the heap arithmetic is unchanged since 2013 (2013-05-03 (b895ebde4340 and its backport fd331e9d5f6b))
- First affected version
- 4.4.0 [exec] (release tag 2013-07-22; the first release with Sorter and IntroSorter, via the branch_4x backport); 4.3.1, the last release before, has no such API [exec: compile error], and its SorterTemplate predecessor stays inside the slice at the same slots [exec]
- Affected versions
- every release from 4.4.0 through 10.6.0 and main (ea57360912ed, 645d4fe14a20); no version has a fix. [exec: a direct heapSort port and a public-API port (IntroSorter subclass in another package, McIlroy quicksort adversary built against each version) both fail on 4.4.0 (-ea and -da), 4.10.4, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3 (-ea and -da), 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), with their controls passing; the finder's JUnit fails on main, 10.5.2 and 9.12.3, the independent reproduction's tests on main, 10.5.2 and 9.12.3; ArrayUtil.introSort on a real Integer[MAX_ARRAY_LENGTH] throws on main and 10.5.2 (-da)] [read: all 116 release tags from 4.4.0 to 10.6.0 in the history clone contain the commit or its backport, and the heapChild and siftDown loop-guard lines are byte-identical in all 116 (rechecked 2026-11-01)]; 8.8.2 to 8.11.3 were not executed one by one (tags not in the history clone; 8.11.4 fails); 10.6.0 (released 2026-10-29): the minimal repro fails with -ea and -da [exec, SHA-1-checked release jars, 2026-11-01], as does 9.1.0 (-da) [exec]. 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, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- formal: Z3
- Upstream and prior reports
- none found (2026-10-26: two scripted prior-report searches with 60 queries (32 mailing-list queries over dev@, java-user@, issues@ and commits@, 10 JIRA-archive, 13 GitHub issue/PR, 3 commit and 2 downstream; 66 and 18 distinct hits, 0 high), the verifiers' earlier runs (the independent reproducer's 31 queries, the intended-behaviour check's 43 queries plus direct list searches; 1 high hit, #8152, read: not this bug), 56 GitHub issue/PR searches over apache/lucene, apache/lucene-solr, apache/lucenenet, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and org:apache, the changed files of all 411 open apache/lucene PRs (5 touch the sorter files, none the heap code), the upstream and local commit history (git log --all -S heapChild finds only the LUCENE-4946 commits) and 6 web queries; 23 hits read, none the same bug); not fixed upstream (apache/lucene main e1c4184cb983 on 2026-10-26: Sorter.java last changed by #426 in 2021, heapChild and siftDown unchanged since 2013). Closest items, not reports: #6010 (LUCENE-4946, the introducing issue; its review discussed the exclusive end index overflowing at 2^31 when sorting a full array, answered 'no real issue as the maximum array size is limited', and never mentions heapChild, siftDown or heap sort), #8152 (LUCENE-7097, depth budget, performance only), #7355 (LUCENE-6293, a fixed TimSorter AIOOBE at extreme scale, precedent), #9866 (LUCENE-8822, open, an UnsupportedOperationException from MSBRadixSorter.compare under heapify/siftDown, a different symptom), #11477 (LUCENE-10441, open, an AIOOBE in BytesRefHash.get through binarySort, not the heap path) and open PR #12800 (makes binarySort protected; does not touch the heap code)
- Lucene commit tested
- main
645d4fe14a20(the minimal test standalone with assertions on and off, also with a 24-byte array header, and through gradle; both fixes in a scratch worktree of645d4fe14a20); the SHA-1-checked release jars 10.6.0, 10.5.2, 9.12.3 and 9.1.0 (the minimal test; 9.1.0 in the fact check, which also re-ranArrayUtil.introSorton a realInteger[MAX_ARRAY_LENGTH]on645d4fe14a20). Earlier, on mainea57360912edanda9779ef3c193: the hunt's direct and public-API tests on 12 versions from 4.4.0 to main, andArrayUtil.introSorton a realInteger[MAX_ARRAY_LENGTH]on main and 10.5.2.
- Finding record
lucene/core/src/java/org/apache/lucene/util/Sorter.java:36 (at ea57360912ed) (@lucene.internal), :75-79 (sort javadoc: 'the slice which starts at from (inclusive) and ends at to (exclusive)'), :81-86 (checkRange rejects only to < from), :254-268 (heapSort javadoc 'used as a fall-back by IntroSorter'; body), :270-274 (heapify), :276-294 (siftDown: :277 'leftChild < to' guard, :279 compare(i, leftChild) before any swap), :296-298 (heapParent with >>> 1), :300-302 (heapChild = ((i - from) << 1) + 1 + from in int)lucene/core/src/java/org/apache/lucene/util/IntroSorter.java:41-45 (at ea57360912ed) (sort: depth budget 2 * MathUtil.log(to - from, 2)), :47-62 (fallback 'Max recursion depth exceeded: fallback to heap sort' at :58-61)lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:509-520 (at ea57360912ed) (introSort(T[], fromIndex, toIndex, comp): 'Sorts the given array slice', no size limit), :32-34 (MAX_ARRAY_LENGTH)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/util/IntroSorter.java:19-25, :42-45, :59-61, :67, :155-158 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/Sorter.java:165-176, :240-241, :254-302; TimSorter.java:183; ArrayIntroSorter.java:40-42; InPlaceMergeSorter.java:44; MathUtil.java:32-35 (at 645d4fe14a20)
- Who is affected
lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:26, :32-34, :509-520; CollectionUtil.java:29, :133; Sorter.java:36; IntroSorter.java:31 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/DocValuesFieldUpdates.java:297-337, :376-378 (at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/util/Sorter.java:36, :48-55, :75-79, :81-86, :254-258; ArrayUtil.java:32-34, :509-520; IntroSorter.java:19-25; package-info.java:35 (at 645d4fe14a20)
- Suggested fix
OpenJDK 25.0.3 src.zip java.base/java/util/PriorityQueue.java:686-687 (siftDownComparable guard)SECURITY.md:46 (robustness fixes must not slow hot paths)
- History
a Lucene clone: git show b895ebde4340, fd331e9d5f6b; git log -L on heapChild and siftDown
- TestIntroSorterHeapSortAtArrayEnd.java (JUnit, one test):
repro/LFV-372/src/org/apache/lucene/util/TestIntroSorterHeapSortAtArrayEnd.java(59 lines)
Made in parallel with this doc from the code at 645d4fe14a20. Its numbers come from its own probe on 645d4fe14a20, run with assertions on and off: a replay of the minimal repro's 60 values (the same heap-sort range and wrapped child as the trace in this doc), and ArrayUtil.introSort on a real Integer[MAX_ARRAY_LENGTH] whose last 60 slots hold those values (10 GB heap), which throws Index -2147483639 out of bounds for length 2147483631, the message on the summary card. The real-array run in this doc uses a 1,000-value tail and gives Index -2147482757.
assets/LFV-372/bug.png(474 KB)
