One missing comparison: TimSorter's merge rule looks at the top three runs only, so the run stack can outgrow its 49 slots.
- TimSort splits the input into runs: stretches that are already sorted. Short ones are extended to a minimum length, 32 to 64 elements in Lucene.
- It pushes each run onto a stack and merges neighbours so that every run is longer than the two above it together. Then run lengths grow at least like Fibonacci numbers, and 40 entries are enough for any
int-sized input. - Lucene's
TimSorterreserves 49 entries and does not check the bound when it pushes a run.
- You sort 1,093,938,049 elements with
CollectionUtil.timSort(orArrayUtil.timSort, or index sorting of a segment with that many documents), laid out as 1,061 ascending runs of 38 to 113,968,681 elements. The input is legal: every run but the one-element last run is longer than the minimum run length of 33. A linear program found the lengths. - You expect a sorted result. If every run on the stack were longer than the two above it together, 40 entries would cover any
int-sized input, and Lucene reserves 49. - But
ensureInvariantschecks that rule only for the top three runs, so a merge can break it one level down unnoticed: stack 1,200 | 800 | 250 | 200, a run of 300 arrives, 250 and 200 merge, and now 1,200 < 800 + 450. - In this input such breaks pile up: after 1,060 runs, 49 runs wait on the stack, and pushing the last run writes slot 50 of a 50-slot array:
ArrayIndexOutOfBoundsException: Index 50 out of bounds for length 50. The data is left unsorted but intact. - Through index sorting,
IndexWriter.commit()throws the same exception and the writer closes with it as its tragic exception: the 1,093,938,049 buffered documents are lost.
Code that sorts 1.09 billion or more items with CollectionUtil.timSort, ArrayUtil.timSort or a TimSorter subclass, and index sorting of a segment that large. The run lengths must follow a pattern a linear program found; no natural data is known to produce it. A plain sort fails loudly, data intact; an index-sorting commit closes the writer and loses uncommitted documents.
Mechanism, terms and numbers995 words
Lucene's TimSorter keeps the sorted stretches (runs) it has found but not yet merged on a stack of 49 entries (runEnds has 1 + STACKSIZE = 50 slots, TimSorter.java:45, :62). The stack stays that small only if every run on it is longer than the two above it together; ensureInvariants (:127-153) checks that only for the top three runs, so a merge can break the rule one level deeper without anyone noticing. In 2015 LUCENE-6293 (#7355) met this flaw and raised the stack from 40 to 49 entries; in 2018 Auger, Jugé, Nicaud and Pivoteau showed for Java's identical check that such a bound still fails, and OpenJDK fixed its TimSort in JDK 11 (JDK-8203864) by also checking the fourth run. Lucene's TimSorter never got that check.
A legal input of 1,093,938,049 elements in 1,061 runs (every run but the one-element last run is at least 38, above minRun(n) = 33, so sort() detects exactly these runs; the lengths were solved for with a linear program) fills all 49 entries, and the 1,061st push throws ArrayIndexOutOfBoundsException: Index 50 out of bounds for length 50 at TimSorter.pushRunLen(TimSorter.java:98). It is reached through the Java-public CollectionUtil.timSort and ArrayUtil.timSort (methods of @lucene.internal classes), any TimSorter subclass, and index sorting, which is supported public API: IndexWriterConfig.setIndexSort plus IndexWriter.commit() on a 1,093,938,049-document in-memory segment (the writer is then closed and the buffered documents are lost) and SortingCodecReader.wrap on a segment that large, both run with a deliberately extreme setup. A plain sort leaves its data unsorted but still a permutation of the input. Every release from 5.1.0 to 10.6.0 and main is affected; the one-line fourth-run check fixes it.
static final int STACKSIZE = 49; // depends on MINRUN // TimSorter.java:45
runEnds = new int[1 + STACKSIZE]; // :62, 50 slots, up to 49 runs
void pushRunLen(int len) { // :97
runEnds[stackSize + 1] = runEnds[stackSize] + len; // :98, no bound check
++stackSize;
}
void ensureInvariants() { // :127
while (stackSize > 1) {
final int runLen0 = runLen(0); // top run
final int runLen1 = runLen(1);
if (stackSize > 2) {
final int runLen2 = runLen(2);
if (runLen2 <= runLen1 + runLen0) { // :135, top three runs only
if (runLen2 < runLen0) { mergeAt(1); } else { mergeAt(0); }
continue;
}
}
if (runLen1 <= runLen0) { mergeAt(0); continue; } // :146
break;
}
}
// sort(from, to), :202-205
do {
ensureInvariants(); // :203
pushRunLen(nextRun()); // :204
} while (runEnd(0) < to);nextRun() (:103-125) takes the next maximal non-descending stretch (or a strictly descending one, reversed) and extends it to at least minRun elements with a binary sort; minRun(length) is 32 to 64 (:67-78).
TimSort wants every run on the stack to be longer than the two runs above it together, and longer than the one directly above it. Then lengths grow at least like Fibonacci numbers from the top down; with runs of at least 32 elements, 40 entries already reach Integer.MAX_VALUE. The stack was sized at 40 on that basis in 2013, and LUCENE-6293 (2015) raised it to 49 after the first counterexample to the three-run check, keeping the check. For this input's n and minRun(n) = 33 the rule would allow at most 35 runs (35 such runs hold at least 836,296,060 elements; 36 would need 1,353,155,492, more than n), the bound the diagram draws.
- Stack, bottom to top: 1,200 | 800 | 250 | 200. Every rule holds: 1,200 > 800 + 250, 800 > 250 + 200, 250 > 200.
- A run of 300 is pushed. The top three: 250 <= 200 + 300, and 250 < 300, so 250 and 200 merge (
mergeAt(1)): 1,200 | 800 | 450 | 300. - The top three again: 800 > 450 + 300 and 450 > 300, so the loop stops.
- One level deeper the rule is now broken: 1,200 < 800 + 450 = 1,250, and nothing checks it again. With the fourth-run check, step 3 sees 1,200 <= 800 + 450 and keeps merging.
The study kept the merge decisions of the published Java crash sequence (Auger et al., ESA 2018) fixed and re-solved its run lengths as a linear program so that every run is at least minRun(n); 17 such sequences were found, from 1,093,938,049 to 1,719,699,526 elements; the three run on the real jar (1,093,938,049, 1,285,317,058 and 1,719,699,526) all crash it. With the smallest, before the 1,061st push the stack holds 49 runs, from 299,699,572 elements at the bottom down to 38 at the top, and the rule is broken at 32 of its levels, for example 299,699,572 < 227,937,415 + 113,968,730 at the bottom (computed with the study's mirror of ensureInvariants; the diagram's probe, which records the real TimSorter's stack, shows the same 49 runs). The push then writes runEnds[50] and throws.
Lucene's larger minRun (32 to 64; the JDK uses 16 to 32) does not protect it: merge decisions depend only on ratios of run lengths, this pattern needs n/minRun of about 2^24.54, and for n between 2^30 and 2^31 Lucene allows up to 2^25. That is also why no input below 2^30 elements is known; smaller inputs with other patterns were not searched.
- Flush.
IndexingChain.flushcallsmaybeSortSegmentfirst (IndexingChain.java:305), which callsSorter.sortAndLeaveUnpacked(:297); that builds a doc comparator with a doc-ID tie-break (index/Sorter.java:225-238) and sorts allmaxDocdoc IDs withDocValueSorter extends TimSorter(index/Sorter.java:85,:132-154).DocumentsWriterPerThread.flushturns any exception into an aborting one and fires a tragic event (DocumentsWriterPerThread.java:585-602). - Re-sort.
SortingCodecReader.wrap(reader, sort)callsnew Sorter(sort).sort(reader)(SortingCodecReader.java:445-446), the same path. The sort is skipped only when the segment is already sorted (index/Sorter.java:133-143).
Exposure and likelihood283 words
- Who. Callers of
CollectionUtil.timSortandArrayUtil.timSort(public methods of@lucene.internalclasses) or of aTimSortersubclass, on 1,093,938,049 or more elements in a run pattern like this one; and applications that use index sorting, the supported public route, on segments of that many documents.FreqProxTermsWriter'sDocOffsetSorter(postings of index-sorted segments) is another in-treeTimSortersubclass; it was not assessed. - Default vs opt-in. The utility path needs no setting. Index sorting is opt-in (
IndexWriterConfig.setIndexSort). - Scale. 1,093,938,049 elements, the smallest overflowing input the study found: below
Integer.MAX_VALUEand belowIndexWriter.MAX_DOCS(IndexWriter.java:208). The executed index routes were deliberately extreme:NoMergePolicy, a 1024 MB RAM buffer, one reused document holding only the sort field, a single segment of 1,093,938,049 documents, a 16 GB heap and about 5 minutes each. With real documents the per-thread hard RAM limit (default 1945 MB,IndexWriterConfig.java:93) makes a single flushed segment that large much less likely. The re-sort route is not bounded by that setting, but it still needs an existing segment of about 1.09 billion documents, anint[maxDoc]doc map (index/Sorter.java:146) and a large heap. The defaultTieredMergePolicycaps merged segments at 5 GB (TieredMergePolicy.java:77); how a normal application would come to hold such a segment was not studied. - Effect. A loud
ArrayIndexOutOfBoundsException. A plain sort leaves its data unsorted but still a permutation of the input. On the flush route theIndexWriteris closed with the exception as its tragic exception, so the uncommitted documents are lost. - Likelihood: contrived. The run lengths must follow a pattern solved for with a linear program; the study's search, which kept one merge pattern fixed, found no smaller legal input. Priority low.
One JUnit test through Java-public methods of @lucene.internal classes: CollectionUtil.timSort on a List<Boolean> view of a FixedBitSet that holds the 1,061 runs (each run is false values ending in one true). Deterministic and single-threaded, about 205 MB of heap, about a minute under Gradle. The array of run lengths is the input itself, the smallest the study found.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.util;
import java.util.AbstractList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.RandomAccess;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestTimSorterRunStackOverflow extends LuceneTestCase {
private static final int[] RUNS = {
71762141, 71328474, 433663, 433659, 867319, 1734637, 3469274, 4625699, 2312850, 2312846,
15146674, 1043270, 521635, 521631, 2086531, 2782041, 1391021, 1391017, 5409525, 4327615,
1081906, 1081902, 2163804, 16189952, 1952643, 38, 1952677, 1952685, 5858002, 38, 27232480,
673551, 673547, 1347094, 1347102, 1347086, 4310699, 1077677, 1077673, 2155346, 13222701,
787070, 787066, 1574131, 2098842, 1049421, 1049417, 5876752, 1469190, 1469186, 2938372,
71762145, 54762751, 2974702, 38, 2974736, 5949471, 5949479, 84, 42, 38, 17737952, 110634,
110630, 110638, 331899, 331907, 165930, 38, 165934, 17848594, 202827, 202823, 202831, 608438,
38, 17848598, 1216949, 1216907, 38, 2433889, 2433910, 2433868, 38, 8112996, 1622598, 1622594,
1622602, 3245197, 1622594, 38, 71762149, 42206581, 113968681, 45, 41, 82, 165, 329, 659,
1317, 2635, 5269, 10539, 21078, 42156, 84312, 168624, 337247, 674494, 1348988, 2697976,
5395952, 10791905, 21456976, 126834, 126830, 253659, 507318, 1014636, 1217565, 811707,
405854, 405850, 4556410, 313838, 156919, 156915, 627630, 38, 836891, 418445, 418441, 1627288,
1301826, 325458, 325454, 650909, 4870256, 190615, 190611, 381223, 762445, 1016593, 508297,
508293, 8158379, 269950, 269946, 539892, 539900, 539884, 1919614, 239955, 239951, 479902,
959803, 2159577, 1057090, 42, 38, 2782046, 434697, 434655, 38, 434701, 434685, 1043265,
695508, 347754, 347750, 21583817, 21207364, 376449, 376445, 376453, 1129343, 2258644, 42, 38,
46, 4065710, 451745, 451741, 451749, 903490, 451741, 1355239, 677582, 38, 677616, 4517464,
621146, 621142, 1242284, 1242305, 1242263, 38, 4517468, 49, 38, 53, 136, 144, 128, 462, 84,
42, 38, 88, 38, 168, 84, 42, 38, 21583821, 13816717, 35400493, 42, 38, 76, 152, 304, 608,
1216, 2432, 4864, 9728, 19456, 38912, 77824, 155648, 311296, 622592, 1245184, 1245192,
3042559, 6738578, 39540, 39536, 79072, 158145, 316290, 421719, 210860, 210856, 1428263,
47751, 47747, 95494, 190988, 381976, 664303, 99648, 99644, 199289, 265718, 132859, 132855,
1476022, 169594, 38, 169628, 169640, 339264, 169632, 169628, 2599103, 64338, 64334, 128669,
257300, 38, 411739, 102937, 102933, 205866, 1260945, 77205, 77201, 154401, 205868, 102934,
102930, 540402, 180133, 90066, 90062, 180137, 180121, 6778127, 6690893, 87229, 87225, 174450,
174458, 174442, 697718, 84, 42, 38, 1196332, 199352, 38, 199386, 398734, 38, 398780, 398734,
42, 38, 1395692, 232586, 38, 232620, 232628, 38, 2093555, 42, 38, 46, 84, 130, 38, 218, 42,
38, 50, 84, 42, 38, 6778131, 4144004, 10922089, 42, 38, 76, 152, 304, 608, 1216, 2432, 4864,
9728, 19456, 38912, 77824, 155648, 311296, 622592, 862803, 2089918, 18070, 18066, 36131,
48175, 24088, 24084, 126458, 42151, 21076, 21072, 42155, 42139, 438525, 25136, 25132, 50263,
67017, 33509, 33505, 203960, 30597, 30593, 61186, 81581, 40790, 40786, 463669, 22468, 22464,
44929, 89857, 179676, 38, 803139, 19958, 19954, 39909, 79817, 127706, 31929, 31925, 63849,
388089, 26954, 26950, 53900, 107763, 38, 172479, 43122, 43118, 86236, 2107995, 1664713,
33027, 33023, 33031, 99077, 99085, 99039, 42, 38, 356723, 39636, 39632, 39640, 79272, 158539,
39637, 39633, 79266, 396367, 132093, 38, 132097, 132135, 79235, 396371, 19825, 19821, 39642,
39650, 79291, 198140, 84, 42, 38, 88, 42, 38, 2107999, 1245065, 3353017, 43, 39, 79, 158,
316, 632, 1264, 2527, 5055, 10109, 20218, 40437, 80873, 161747, 323493, 641437, 5550, 5546,
11091, 14788, 7394, 7390, 38817, 12938, 12896, 38, 12942, 12926, 134592, 7717, 7713, 15427,
20569, 10285, 10281, 62596, 9392, 9388, 18776, 25035, 12518, 12514, 142317, 11030, 11026,
11034, 22059, 33093, 22051, 246503, 6101, 6097, 12194, 24388, 39020, 9757, 9753, 19506,
119684, 7128, 7124, 14248, 18998, 9499, 9495, 53188, 13299, 13295, 26590, 646993, 631094,
15896, 38, 15930, 31860, 42468, 21253, 21211, 38, 146736, 1955, 38, 1959, 3948, 6315, 1581,
1577, 3153, 148699, 4603, 38, 4637, 9235, 38, 148703, 18543, 42, 38, 18547, 18627, 18539, 38,
74326, 42, 38, 76, 84, 42, 38, 646999, 382530, 1029483, 42, 38, 76, 152, 304, 608, 1216,
2432, 4864, 5398, 9736, 24862, 49725, 99450, 197693, 1206, 1202, 2404, 4808, 9617, 12822,
6411, 6407, 41980, 2895, 1448, 1444, 5782, 7710, 3855, 3851, 14992, 11988, 2999, 2995, 5990,
44883, 5372, 38, 5406, 5414, 10820, 5406, 75464, 1871, 1867, 3733, 3741, 3725, 11945, 2988,
2984, 5969, 36636, 2185, 2181, 4361, 5815, 2908, 2904, 16278, 4072, 2036, 2032, 8135, 198907,
152323, 3950, 3946, 3954, 7899, 19661, 84, 42, 38, 36523, 3046, 3042, 6085, 12131, 38, 12177,
42, 38, 46, 39577, 3084, 3080, 3088, 9211, 38, 54658, 3418, 3414, 3422, 6836, 10259, 27304,
42, 38, 50, 84, 42, 38, 198911, 116938, 315803, 42, 38, 76, 152, 304, 608, 1216, 2432, 4864,
9728, 11056, 30512, 60652, 373, 369, 738, 1476, 2951, 3935, 1968, 1964, 12879, 891, 445, 441,
1773, 2365, 1182, 1178, 4598, 3674, 920, 916, 1833, 13777, 830, 826, 834, 2486, 4933, 38,
23143, 577, 573, 1145, 2253, 38, 3663, 918, 914, 1828, 11231, 673, 669, 1337, 1783, 891, 887,
4987, 1249, 1245, 2490, 61033, 60449, 580, 576, 1151, 2272, 2310, 2264, 42, 38, 8389, 840,
836, 1673, 1681, 3354, 1654, 38, 1658, 9237, 1363, 1359, 2717, 2725, 2709, 9241, 772, 386,
382, 1535, 1543, 772, 768, 3079, 614, 610, 622, 1228, 614, 610, 61037, 35847, 96838, 42, 38,
76, 152, 304, 608, 1216, 2432, 4864, 8978, 18541, 165, 161, 323, 430, 215, 211, 1128, 375,
333, 38, 379, 363, 3890, 227, 223, 446, 595, 297, 293, 1804, 273, 269, 539, 718, 359, 355,
4125, 362, 38, 396, 792, 800, 784, 7111, 181, 177, 354, 708, 1131, 285, 281, 561, 3431, 242,
238, 476, 913, 38, 1520, 382, 378, 756, 18714, 13817, 435, 38, 439, 881, 915, 873, 38, 3226,
388, 38, 392, 815, 1588, 42, 38, 46, 3622, 288, 284, 292, 822, 38, 4842, 500, 38, 534, 542,
1609, 1617, 747, 42, 38, 785, 38, 18718, 11005, 29671, 48, 44, 87, 175, 350, 700, 1399, 2799,
5555, 42, 38, 75, 151, 302, 310, 102, 98, 106, 1177, 43, 39, 78, 157, 313, 543, 84, 80, 160,
214, 107, 103, 1228, 91, 38, 95, 221, 358, 84, 42, 38, 2123, 69, 65, 130, 138, 122, 450, 70,
66, 132, 177, 88, 84, 528, 246, 42, 38, 733, 117, 59, 55, 188, 38, 273, 179, 90, 86, 5605,
4450, 197, 38, 231, 239, 38, 471, 42, 38, 46, 766, 61, 57, 113, 151, 75, 71, 234, 77, 73, 81,
1345, 42, 38, 46, 84, 38, 1503, 86, 82, 90, 229, 263, 221, 38, 491, 84, 42, 38, 88, 59, 176,
130, 42, 38, 5609, 3636, 9199, 42, 38, 76, 152, 304, 386, 994, 1936, 52, 48, 96, 193, 306,
79, 75, 149, 934, 60, 56, 111, 148, 74, 70, 411, 105, 101, 201, 1997, 1314, 109, 105, 210,
378, 42, 38, 428, 99, 38, 133, 154, 112, 38, 2001, 803, 2386, 414, 368, 42, 38, 46, 84, 154,
50, 46, 54, 38, 1048, 264, 218, 42, 38, 50, 84, 42, 38, 268, 75, 272, 67, 63, 84, 42, 38, 1
};
public void testTimSortLegalInput() {
int size = Arrays.stream(RUNS).sum();
FixedBitSet bits = new FixedBitSet(size);
int end = -1;
for (int i = 0; i < RUNS.length - 1; i++) {
end += RUNS[i];
bits.set(end);
}
class BitList extends AbstractList<Boolean> implements RandomAccess {
@Override
public Boolean get(int i) {
return bits.get(i);
}
@Override
public Boolean set(int i, Boolean value) {
boolean old = bits.get(i);
if (value) {
bits.set(i);
} else {
bits.clear(i);
}
return old;
}
@Override
public int size() {
return size;
}
}
try {
CollectionUtil.timSort(new BitList(), Comparator.<Boolean>naturalOrder());
} catch (ArrayIndexOutOfBoundsException e) {
// Expected: the list is sorted; actual: TimSorter's 49-entry run stack overflows.
throw new AssertionError("timSort of " + size + " elements threw " + e, e);
}
assertEquals(size - (RUNS.length - 1), bits.nextSetBit(0));
}
}
cp $REPRO/TestTimSorterRunStackOverflow.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.util.TestTimSorterRunStackOverflow -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
- Expected
- The list comes back sorted: 1,093,936,989 falses, then 1,060 trues.
- Observed
AssertionError: timSort of 1093938049 elements threw java.lang.ArrayIndexOutOfBoundsException: Index 50 out of bounds for length 50, fromTimSorter.pushRunLen(TimSorter.java:98), with assertions on and off.
More files, environment, full output458 words
- The input. Run i is
RUNS[i] - 1false values followed by one true, so it is ascending and ends at a descent; the last run is a single false. n = 1,093,938,049,minRun(n)= 33, and every run except the last is at least 38, sosort()detects exactly these runs. It is the smallest legal overflowing input the study found; its search kept one merge pattern fixed. - Why a bit set. A
RandomAccesslist view of aFixedBitSetkeeps 1.09 billion elements in about 137 MB. With the sorter's temp array (Object[n / 64], about 68 MB,CollectionUtil.java:86,:166) the test needs about 205 MB of heap: it ran out of memory with-Xmx160m, and fails the same way with-Xmx224mand with 512 MB, the Gradle test default. It takes about 10 s with a plain JVM and about a minute under Gradle's default test JVM flags. - What it asserts. That the sort does not throw, and that the result is sorted: the first set bit is at n - 1,060.
- Index-sorting route (prose only). The same runs as
NumericDocValuesFieldvalues in doc-ID order, withsetIndexSort(new Sort(new SortField("v", SortField.Type.INT)))andsetRAMBufferSizeMB(1024):commit()of 1,093,938,049 buffered documents throws the same exception and closes the writer;SortingCodecReader.wrapon a committed unsorted segment of that size throws too. That program needs a 16 GB heap and about 5 minutes, so it is supporting evidence, not a second repro. - Regression test. The same test; given its size it may belong under
@Nightly, likeTestTimSorterWorstCase../gradlew tidy(google-java-format) re-indents theRUNSarray and changes nothing else, but the file gets one line shorter, so the test frames then read:132and:135.
| 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 $REPRO/TestTimSorterRunStackOverflow.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.util.TestTimSorterRunStackOverflow -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
# Gradle, main 645d4fe14a20, seed DEADBEEF, assertions on, default 512 MB test heap
TestTimSorterRunStackOverflow > testTimSortLegalInput FAILED
java.lang.AssertionError: timSort of 1093938049 elements threw java.lang.ArrayIndexOutOfBoundsException: Index 50 out of bounds for length 50
at __randomizedtesting.SeedInfo.seed([DEADBEEF:202A3451691B6A42]:0)
at org.apache.lucene.util.TestTimSorterRunStackOverflow.testTimSortLegalInput(TestTimSorterRunStackOverflow.java:136)
Caused by:
java.lang.ArrayIndexOutOfBoundsException: Index 50 out of bounds for length 50
at org.apache.lucene.util.TimSorter.pushRunLen(TimSorter.java:98)
at org.apache.lucene.util.TimSorter.sort(TimSorter.java:204)
at org.apache.lucene.util.CollectionUtil.timSort(CollectionUtil.java:166)
at org.apache.lucene.util.TestTimSorterRunStackOverflow.testTimSortLegalInput(TestTimSorterRunStackOverflow.java:133)
# index-sorting flush route, stock main, assertions off (plain main(), 16 GB heap)
route=flush buffered docs=1093938049
java.lang.AssertionError: IndexWriter.commit() with index sort <int: "v"> and 1093938049 buffered docs threw
java.lang.ArrayIndexOutOfBoundsException: Index 50 out of bounds for length 50; writer open: false,
tragic exception: java.lang.ArrayIndexOutOfBoundsException: Index 50 out of bounds for length 50
# frames: TimSorter.pushRunLen(:98) <- TimSorter.sort(:204) <- index.Sorter.sort(Sorter.java:154)
# <- Sorter.sortAndLeaveUnpacked(:238) <- IndexingChain.maybeSortSegment(:297) <- IndexingChain.flush(:305)
# <- DocumentsWriterPerThread.flush(:492) <- ... <- IndexWriter.commit(:4215)Sorter.sortpromises to "Sort the slice", with no size limit or documented exception;setIndexSortaccepts segments up toMAX_DOCS.- LUCENE-6293 fixed this exact failure as a bug in 2015 by enlarging the stack; the nightly
TestTimSorterWorstCaseexpectssort()to finish. - OpenJDK fixed the same residual in JDK 11 with the fourth-run check.
The full argument207 words
- Contract.
Sorter.sort(from, to): "Sort the slice which starts at from (inclusive) and ends at to (exclusive)." (util/Sorter.java:75-79).CollectionUtil.timSortdocuments onlyIllegalArgumentExceptionfor lists without random access (CollectionUtil.java:155-162);ArrayUtil.timSortdocuments no exception (ArrayUtil.java:558-600); theTimSorterclass javadoc names no size limit (TimSorter.java:21-40).IndexWriterConfig.setIndexSort: "Set the Sort order to use for all (flushed and merged) segments." (IndexWriterConfig.java:504). - Intent.
STACKSIZE = 49; // depends on MINRUNsays the stack is meant to be large enough. LUCENE-6293 is listed under Bug Fixes in CHANGES 5.1.0 ("Fixed TimSorter bug.") and added the worst-case test that is now the@NightlyTestTimSorterWorstCase, which drives aTimSorterwith an adversarial run pattern and expectssort()to finish. - The 2015 discussion named both fixes. Adrien Grand on #7355: "There are two suggested ways to fix the issue, either change the max stack size or change the condition upon which consecutive runs are collapsed. I opted for the first one which is easier to implement." This bug report is about the second one.
- Precedent. OpenJDK treated the same residual as a bug (JDK-8203864, fixed in JDK 11). JDK 25's
java.util.TimSort.mergeCollapsehas the fourth-run check and credits the Auger et al. analysis in its comment.
The devil's-advocate check210 words
The strongest case for "not a bug": the classes are @lucene.internal; the input is absurd, 1.09 billion elements in an order solved for with a linear program, and the failure is loud. In 2015 Adrien Grand wrote "This bug cannot corrupt data, it can just raise an ArrayIndexOutOfBoundsException if you happen to have a large array which has a very specific pattern", and Robert Muir asked for "no high-risk fixes for low-risk bugs". The cross-model adversary refuted on the @lucene.internal ground alone, while agreeing on the reproduction, the root cause and the fix.
Why it fails: @lucene.internal is a compatibility notice ("might change in incompatible ways in the next release"), not permission to crash on valid input. The objection does apply to the minimal test, which goes through internal classes; but the same overflow is reached through supported public API, IndexWriterConfig.setIndexSort plus IndexWriter.commit(), where it closes the writer, although only with a deliberately extreme setup (one in-memory segment of 1.09 billion one-field documents). The 2015 maintainers held the same "very specific pattern, no corruption" view and still fixed it, believing 49 was enough; it is not. The condition fix that looked riskier in 2015 is now the JDK's own code. What the argument does establish: priority is low.
Recommended A. Also check the fourth run (the JDK-8203864 condition)
Add the fourth-run check to ensureInvariants, the condition OpenJDK adopted in JDK 11: also merge when stackSize > 3 && runLen(3) <= runLen2 + runLen1. One line; the stack can stay at 49 entries. Validated: the test passes with assertions on and off, and so do the closest sorter and index-sorting suites and all 8,898 core tests.
if (stackSize > 2) {
final int runLen2 = runLen(2);
- if (runLen2 <= runLen1 + runLen0) {
+ // also check the fourth run from the top (as in OpenJDK's JDK-8203864 fix and CPython's
+ // listsort), otherwise the invariant can break deeper in the stack and overflow runEnds
+ if (runLen2 <= runLen1 + runLen0 || (stackSize > 3 && runLen(3) <= runLen2 + runLen1)) {
// merge the smaller of 0 and 2 with 1
if (runLen2 < runLen0) {
mergeAt(1);All options and trade-offs3 options · 556 words
Three options. Option A was applied in a scratch worktree of main 645d4fe14a20 and run with Gradle against the minimal test, the closest suites, all core tests and core's static checks; earlier runs also tested it, with TimSorter shadowed, against the index-sorting re-sort route.
One line in ensureInvariants (TimSorter.java:135). It is the JDK's mergeCollapse condition n > 1 && runLen[n-2] <= runLen[n] + runLen[n-1] in TimSorter's top-of-stack indexing, with the same choice of which run to merge. The JDK's comment says the invariants then hold upon entry to its method; that is about the JDK's loop, not a proof for Lucene's, and no general proof for TimSorter was written. What was checked: with the fix the stack peaks at 19 runs on this input and on each of the study's 17 legal overflowing sequences (a run-length mirror), and the diagram's probe on the real code sees at most 18 runs before a push; both are far below 49. The diff's comment names CPython's listsort for its 2015 change (bpo-23515); current CPython was not checked.
if (stackSize > 2) {
final int runLen2 = runLen(2);
- if (runLen2 <= runLen1 + runLen0) {
+ // also check the fourth run from the top (as in OpenJDK's JDK-8203864 fix and CPython's
+ // listsort), otherwise the invariant can break deeper in the stack and overflow runEnds
+ if (runLen2 <= runLen1 + runLen0 || (stackSize > 3 && runLen(3) <= runLen2 + runLen1)) {
// merge the smaller of 0 and 2 with 1
if (runLen2 < runLen0) {
mergeAt(1);Trade-offs
Changes the merge order only when the fourth-run rule is broken (the small walk above is such a state); how often that happens on real data, and any performance effect, were not measured. One extra comparison per loop iteration. In 2015 this was the option judged harder to implement; it is now the JDK's own code.
What the checks validated
- The minimal test fails on stock main under Gradle and passes with the fix, with assertions on (73.6 s) and off (69.6 s), in a scratch worktree of main
645d4fe14a20, seedDEADBEEF. - Closest suites:
TestTimSorter,TestCollectionUtil,TestArrayUtil,TestIndexSortingandTestSortingCodecReader, 97 tests, 0 failures; the@NightlyTestTimSorterWorstCasewith-Ptests.nightly=truepasses. - All of
:lucene:core:test: 8,898 tests, 322 skipped, 0 failures, the minimal test included. :lucene:core:check -x testpasses once./gradlew tidyhas re-indented the test'sRUNSarray; the fix itself needed no reformatting.- Earlier, with
TimSortershadowed: the index-sorting re-sort route on the 1,093,938,049-document segment passes (it fails on stock main), and 32 upstream sorter tests pass.
Size the stack from Auger et al.'s height bound for the three-run check, which the study reads as at most 86 entries for n < 2^31.
Trade-offs
Keeps every merge decision unchanged and costs 37 more ints per sort, but keeps a broken invariant and relies on a published bound once more, which is what failed after 2015. Not validated.
Open #10671 (LUCENE-9632) discusses replacing Lucene's TimSort with the JDK's.
Trade-offs
Not a fix for this bug: maintainers there note that TimSorter must stay for the Sorter API (parallel arrays), and index sorting's DocValueSorter would keep using it.
A service that sorts, or index-sorts, more than a billion items in an order an outside party controls could see that sort or the commit running it fail loudly; a failed commit closes the writer and loses its uncommitted documents, but nothing is silently wrong and no memory is corrupted.
Non-formal. An AI tool replayed the published 2018 Java TimSort crash sequence through TimSorter's internals, but that sequence is not a legal input for Lucene (its runs are too short). An independent verifier kept its merge decisions, re-solved the run lengths with a linear program so that every run is legal, and crashed the stock jar.
The method in full176 words
- An AI tool replayed the crash sequence that Auger, Jugé, Nicaud and Pivoteau published for Java's TimSort (ESA 2018) through TimSorter's package-private internals, and the stack overflowed. That sequence has runs of 17 elements, below Lucene's
minRun(n)= 33, and the realsort()of that exact array succeeds (maximum stack depth 44). So the finder concluded, wrongly, that no reachable overflow was known. - An independent verifier recorded the 4,138 comparisons
ensureInvariantsmakes on that trace as linear constraints, minimised n with every run at least m (HiGHS), and rounded the solution run by run while keeping every merge decision. That gave 17 legal sequences from 1,093,938,049 to 1,719,699,526 elements; the three run on the stock jar crashed it, throughCollectionUtil.timSort, aTimSortersubclass and, at 1,285,317,058,ArrayUtil.timSort(Boolean[]). The Python mirror and the linear program only found inputs; every behavioural claim comes from the real jar. - It was later reduced to one test with the smallest input found (Java-public methods of
@lucene.internalclasses), and a tie-break executed the index-sorting routes, which are supported public API.
No public report of the residual: 159 scripted queries plus manual GitHub, JIRA and mailing-list searches (2026-11-01). The closed parent #7355 (LUCENE-6293, 2015) is the same code and symptom, fixed incompletely; OpenJDK's JDK-8203864 is the same residual in another project.
Searches4 searches
Closest items, none a report of this bug: #7355 (LUCENE-6293 "TimSort bug", closed 2015-02-26), the parent: same code, root cause and symptom, fixed by 40 -> 49, all 14 comments from 2015; JDK-8203864 (OpenJDK, fixed in JDK 11), the same residual in another project, not linked from any Lucene item; #10671 (LUCENE-9632, open), replacing Lucene's TimSort with the JDK's, where the run stack never comes up; PR #12800 (open, stale), the only open PR touching TimSorter.java, which adds a minRunLength hook and does not discuss the stack. Not a report but worth knowing: the Lucene.NET port still has STACKSIZE = 40. Gaps: searches can miss items with other wording; only the first 400 characters of each list mail were scored mechanically.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-22, 2026-11-01 | scripted searches (60 issue/PR and 3 commit queries, e.g. TimSorter, ensureInvariants, pushRunLen, mergeCollapse, STACKSIZE, JDK-8203864, "TimSort ArrayIndexOutOfBoundsException") and 13 manual searches; 30 items read in full; all 409 open PRs listed with their files | no report; #7355 is the closed parent; PR #12800 is the only open PR on the file |
| JIRA archive (LUCENE, SOLR) | 2026-10-22, 2026-11-01 | 10 scripted queries and 7 manual ones, e.g. TimSort, TimSorter, mergeCollapse, pushRunLen, runEnds, Auger | only LUCENE-6293 and unrelated items |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-11-01 | 76 scripted queries and 15 manual ones, e.g. LUCENE-6293, Auger, Jugé, Nicaud, 8203864 | only the 2015 LUCENE-6293 notifications |
| Downstream and web | 2026-11-01 | TimSorter in Elasticsearch, OpenSearch, Solr and Lucene.NET; 10 web searches | no report; JDK-8203864 and the ESA 2018 paper, neither mentioning Lucene |
The 49-entry stack comes from LUCENE-6293 (3ec9440e945, 2015-02-26), first released in 5.1.0; every sampled release from 5.1.0 to 10.6.0 and main fails at index 50. The three-run check itself dates from LUCENE-4839 (2013, 4.3.0), where the same input overflows the old 40-entry stack at index 41.
Versions tested5 rows
- The residual (this bug):
3ec9440e945(trunk) /da5f4038b88(branch_5x), "LUCENE-6293: Fixed TimSorter bug." by Adrien Grand, 2015-02-26 (#7355): its only production change wasSTACKSIZEfrom 40 to 49 (it also added a worst-case test and a CHANGES entry); first released in 5.1.0. - The root defect: LUCENE-4839 "Sorter API: Use TimSort to sort doc IDs and postings lists" (migrated as #5904;
8613df447f0on trunk, backport51b3f46aadc, 2013-03-16) added the three-run check and a 40-entry stack; first released in 4.3.0. In 4.3.0 to 5.0.0 the same input overflows at index 41, which is the original LUCENE-6293 bug (that one also hits much smaller inputs). The finding record's introducing commit,3ec9440e94, is the incomplete fix (the 49-entry stack), not the origin. ensureInvariantsis textually identical (whitespace aside) in 4.3.0'sSorterTemplateand main'sTimSorter;TimSorter.javais the same blob in 9.12.3, 10.0.0, 10.6.0, main and upstream mainf815e679bf6c(2026-11-01), whose last commit to the file is0fcf9c825f1f(#426, 2021, javadocs only).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | fails at index 50 with assertions on and off (Gradle and standalone); passes with fix A |
| 10.6.0 | affected | fails at index 50 with assertions on and off; the minimal test fails too |
| 5.1.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 | affected | fail at index 50 (release jars, same input) |
| 4.3.0, 4.3.1, 4.4.0, 4.10.4, 5.0.0 | 40-entry stack | fail at index 41: the original LUCENE-6293 form of the same defect |
| 4.2.1 and earlier | no TimSort | CollectionUtil.timSort does not exist (compile error) |
The study verifier, the repro minimization, a re-verification and a tie-break all call it real; a cross-model adversary reproduced it and agreed on the root cause and the fix, and refuted only because the entry point is @lucene.internal, which the tie-break answered by running the index-sorting routes.
Checks and verdicts319 words
The cross-model reviewer was OpenAI GPT-5.6; it ran its own test (a JDK BitSet with a reverse comparator and a small control) with assertions on and off.
| Check | Verdict | What it found |
|---|---|---|
| study verifier | real | Built 17 legal overflowing inputs with a linear program; the three run on the real jar (1,093,938,049 to 1,719,699,526 elements) crash it through TimSorter.sort, CollectionUtil.timSort and ArrayUtil.timSort(Boolean[]). |
| minimisation | verified | One test with the smallest input found (1,093,938,049 elements), through Java-public methods of @lucene.internal classes; fails with assertions on and off, also with a 512 MB heap; passes with the fix. |
| re-verification | real | Re-ran the test on main (on and off), 10.5.2 (on and off) and 9.12.3; the fix passes it and 32 upstream sorter tests. |
| cross-model adversary | not real (contract only) | Reproduced the overflow with its own test, agreed on the root cause and the fix, and refuted only because CollectionUtil.timSort is @lucene.internal. |
| Second adjudication | real | Ran the flush route (setIndexSort + commit()) and the re-sort route (SortingCodecReader.wrap) on 1,093,938,049 documents: both fail; the re-sort route and the nightly worst-case test pass with the fix. |
| history | origin corrected | The three-run check dates from 2013 (4.3.0); the 49-entry residual fails on every sampled release from 5.1.0 to 10.6.0. |
| fact checks | confirmed; wording corrected | Independent fact checks of this doc, one by OpenAI GPT-5.6, re-ran the repro and fix A and recomputed the input facts with their own mirrors. They found no error in the bug, the repro or the fix; the wording corrections they asked for (heap, internal-API scope, effect on the writer, evidence) are applied. |
| this doc's runs | reproduces; fix validated | Gradle: the test fails on stock main and passes with fix A, assertions on and off; 97 closest-suite tests, the nightly worst-case test and all 8,898 core tests pass with the fix. |
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 438 KB
Finding record, Lucene and upstream sources, repro files and diagram notes625 words
- Registry title
- TimSorter overflows its 49-entry run stack on legal inputs of about 1.1 billion elements
- Area
- util / sorters
- Priority
- Low, rank 1194: TimSorter overflows its run stack only on at least 1.09 billion elements in an LP-solved run pattern, loud and practically unreachable.
- Impact class
- crash
- Impact note
- Only sorts of n >= 1,093,938,049 elements with a specific 1,061-run pattern through ArrayUtil.timSort, CollectionUtil.timSort, another TimSorter subclass or index sorting of a segment that large (IndexWriter.commit() with an index sort, SortingCodecReader.wrap), both index routes executed; no smaller legal input is known; low likelihood.
- Summary
- You sort about 1.09 billion or more elements with ArrayUtil.timSort or CollectionUtil.timSort, arranged in a specific pattern of 1,061 runs. You expect a sorted result. Instead TimSorter's fixed 49-entry run stack overflows and the sort throws ArrayIndexOutOfBoundsException.
- Introduced
- 3ec9440e94 (LUCENE-6293) raised STACKSIZE from 40 to 49 without the fourth-run check (2015-02-26)
- First affected version
- 5.1.0 [exec] (the 49-entry stack of LUCENE-6293, 3ec9440e94, 2015); the root defect, the three-run check of LUCENE-4839 (2013), since 4.3.0 [exec: 4.3.0 to 5.0.0 overflow their 40-entry stack at index 41]
- Affected versions
- 5.1.0 through 10.6.0 and main [exec: overflow at index 50 on 5.1.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main 645d4fe14a20; the minimal test also on 10.6.0, 10.5.2 and 9.12.3; main also at n = 1,285,317,058 and 1,719,699,526]; the same root defect with the older 40-entry stack in 4.3.0 through 5.0.0 [exec: overflow at index 41 on 4.3.0, 4.3.1, 4.4.0, 4.10.4 and 5.0.0]; 4.2.1 and earlier have no TimSort [exec]; TimSorter.java is the same blob in 9.12.3, 10.0.0, 10.6.0, main and upstream main f815e679bf6c [read]
- 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
- non-formal: code reading
- Upstream and prior reports
- none found as a report of this residual (study searches 2026-10-22); parent LUCENE-6293 (#7355) is closed; cf. JDK-8203864
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test with assertions on and off standalone and through Gradle in a scratch worktree (2026-11-01), and the recommended fix in that worktree with the test and the closest suites; the index-sorting flush and re-sort routes on a 1,093,938,049-document segment (both fail; the re-sort route passes with the fix). Releases: the minimal test on 10.6.0, 10.5.2 and 9.12.3; a plain-Java probe with the same input on sampled releases: 4.2.1 (no TimSort), 4.3.0 to 5.0.0 (overflow at index 41) and 5.1.0 to 10.6.0 (overflow at index 50). Read: upstream mainf815e679bf6c(2026-11-01) has the sameTimSorter.java. All runs used OpenJDK 25.0.3 on Linux aarch64.
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/util/TimSorter.java:43-46, :62, :67-78, :97-100, :103-125, :127-153, :196-208 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/Sorter.java:85-129, :132-171, :220-238; IndexingChain.java:291-305; DocumentsWriterPerThread.java:485-492, :585-605; SortingCodecReader.java:441-447 (at 645d4fe14a20)
- Who is affected
lucene/core/src/java/org/apache/lucene/util/CollectionUtil.java:29, :155-167; ArrayUtil.java:26, :558-602 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:208; IndexWriterConfig.java:93, :504-505; TieredMergePolicy.java:77; Sorter.java:146; FreqProxTermsWriter.java:313-316, :403 (at 645d4fe14a20)
- Minimal repro
lucene/core/src/java/org/apache/lucene/util/CollectionUtil.java:78-90, :163-167 (ListTimSorter temp array of size n / 64) (at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/util/Sorter.java:75-79; CollectionUtil.java:155-162; ArrayUtil.java:558-600; TimSorter.java:21-45 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:504-505 (at 645d4fe14a20)lucene/core/src/test/org/apache/lucene/util/TestTimSorterWorstCase.java:26-73 (@Nightly, testWorstCaseStackSize, sort at :72) (at 645d4fe14a20)JDK 25 src.zip java.base/java/util/TimSort.java:420-450 (mergeCollapse and its comment)
- Suggested fix
JDK 25 src.zip java.base/java/util/TimSort.java:420-450 (mergeCollapse), :182-184 (stack length 49)
- TestTimSorterRunStackOverflow.java:
repro/LFV-043/src/org/apache/lucene/util/TestTimSorterRunStackOverflow.java(140 lines)
assets/LFV-043/bug.png(438 KB)
