LogMergePolicy groups segments into size levels and merges up to mergeFactor segments of one level at a time. The end of each window is the int sum start + mergeFactor, which overflows when mergeFactor is Integer.MAX_VALUE.
start + mergeFactor = 1 + 2,147,483,647 wraps to -2,147,483,648, and levels.get(-2147483648) throws inside commit(). Select the still to open it at full size. Generated with AI, checked against the code.- Use
LogDocMergePolicywithsetMergeFactor(Integer.MAX_VALUE): the setter only rejects values below 2, and some applications use this value to mean "never merge" during a bulk load. Index 20,000,000 documents into one segment and commit. - Add 1 document and commit. You expect 2 segments and 20,000,001 documents, which is what
Integer.MAX_VALUE - 1gives on the same data. - The policy sorts segments into size levels. The 20,000,000-document segment is more than 9,975,792 (= mergeFactor^0.75) times larger than the 1-document one, so the small one starts a second level at position
start = 1. Its window endstart + mergeFactor= 1 + 2,147,483,647 does not fit in anintand becomes -2,147,483,648. - Nothing rejects the negative end: the loop test
end <= 1 + uptopasses and the code readslevels.get(-2147483648).commit()throwsIndexOutOfBoundsException: Index -2147483648 out of bounds for length 2; the last commit still has 20,000,000 documents. - With the default settings, every later commit, near-real-time reader open and flush of new documents fails the same way until the factor is lowered, and
close()throws and drops uncommitted documents. On 9.5.0-10.1.0 the same overflow is anOutOfMemoryErrorthat closes the writer; up to 9.5.0, 2,000 + 1 documents are enough.
Applications that opt into LogDocMergePolicy or LogByteSizeMergePolicy and set mergeFactor to Integer.MAX_VALUE, a known way to say "never merge" during bulk loads. Since 9.6.0 it also takes a newest segment about 10 million times smaller than the largest one, such as a small flush after a 12-million-document segment; up to 9.5.0, 2,000 + 1 documents.
Mechanism, terms and numbers751 words
LogMergePolicy sorts segments into size levels (the log, base mergeFactor, of each segment's size) and looks at windows of mergeFactor segments within a level; the end of each window is int end = start + mergeFactor. setMergeFactor accepts any value of 2 or more, including Integer.MAX_VALUE, which applications have used to mean "never merge". For every level after the first, start is at least 1, so the sum overflows to a negative number; the negative end passes the loop test, end - start overflows back to Integer.MAX_VALUE, and the minimum-size packing step reads levels.get(end) with that negative index (-2147483648 when the level starts at position 1). With the default IndexWriterConfig, IndexWriter asks the merge policy on every commit, near-real-time reader open and flush of new documents, so all of them throw IndexOutOfBoundsException until the factor is lowered. On main a 20,000,000-document segment followed by a 1-document flush is enough (a size ratio above 9,975,792); up to 9.5.0, 2,000 + 1 documents were. The overflow is as old as the class: 19 releases from 2.3.0 to 10.5.2 and main fail, only the exception changed over time, and on 9.5.0-10.1.0 (by code reading from 9.3.0) it is an OutOfMemoryError that closes the writer and loses uncommitted documents.
- Size level:
LogMergePolicygives each segment the levellog(size) / log(mergeFactor)(size = documents forLogDocMergePolicy, bytes forLogByteSizeMergePolicy). A level runs from its first segment to the last segment whose level is withinLEVEL_LOG_SPAN= 0.75 of the largest remaining level (2 x 0.75 when that largest one is below the minimum merge size); the segments after it start the next level. - Window: inside a level,
findMergeslooks at runs ofmergeFactorsegments, fromstarttoend = start + mergeFactor, and merges a run once it is full.startis the position of the level's first segment in the segment list. - Packing step (since #14166, 10.2.0): when a full window is still smaller than the minimum merge size, it keeps adding the next segments,
levels.get(end),levels.get(end + 1), ...
setMergeFactorchecks onlymergeFactor < 2(LogMergePolicy.java:109-112). WithInteger.MAX_VALUE= 2,147,483,647,log(mergeFactor)is 21.49.- Segments of 20,000,000 and 1 documents get levels 0.782 and 0. The first level spans 0.032 to 0.782 and holds only segment 0; segment 1 starts the second level, so
start = 1. In general a second level exists exactly when the last segment in the list (normally the newest flush) is more thanmergeFactor^0.75= 9,975,792 times smaller than the largest one (if the largest is above the minimum merge size); order matters, so[20M, 1, 20M]stays one level. int end = start + mergeFactor(:596) = 1 + 2,147,483,647 = 2,147,483,648, which wraps to -2,147,483,648. The same expression is at:680.while (end <= 1 + upto)(:597) is true for a negativeend; the window loopfor (i = start; i < end; ...)runs zero times, so the window looks empty and small;end - startwraps back to 2,147,483,647, so the packing test at:629-632passes.- The packing loop calls
levels.get(end)(:636):IndexOutOfBoundsException: Index -2147483648 out of bounds for length 2. For a level that starts at positionstart,endisInteger.MIN_VALUE + (start - 1), so the index is -2147483647 when it starts at 2. Before 10.2.0 the same state reachednew ArrayList<>(end - start), an array of 2,147,483,647 elements:OutOfMemoryError: Requested array size exceeds VM limit; on main, settings that make the packing test false (for example a minimum merge size of 0) reach that allocation too (read, not run). IndexWriter.commit()asks the policy for merges throughMergePolicy.findFullFlushMerges(MergePolicy.java:693), so the exception comes out ofcommit(); the same happens on near-real-time reader open and on flushes of new documents.
Direct findMerges calls on [N, 1] documents at Integer.MAX_VALUE: N = 2,000, 1,000,000, 9,000,000 and 9,900,000 return no merge; N = 10,000,000, 12,000,000 and 20,000,000 throw. Integer.MAX_VALUE - 1 and 10 return no merge on [20,000,000, 1]. The overflow needs mergeFactor > Integer.MAX_VALUE - start, so in practice only Integer.MAX_VALUE or values within about the segment count of it (Integer.MAX_VALUE - 1 fails once the second level starts at position 2). 1,000,000,000 passed the audit's 12,000,000 + 1 scenario and could overflow only for a level that starts after segment 1,147,483,647.
Exposure and likelihood417 words
- Who is exposed. The default policy is
TieredMergePolicy, so only applications that chooseLogDocMergePolicyorLogByteSizeMergePolicyand set the factor toInteger.MAX_VALUE(or within about the segment count of it). The idiom is known: LUCENE-2331 (#3407, 2010) quotes the advice "if you want to prevent merges, set mergeFactor to a high enough value" and names "mergeFactor to Int.MAX_VAL", and third-party code callssetMergeFactor(Integer.MAX_VALUE). In 2.3.0-3.1LogByteSizeMergePolicywas the default andIndexWriter.setMergeFactorreached it. - What it takes on 9.6.0 and later. The last segment (normally the newest flush) more than about 1e7 times smaller than the largest one: for example a 1-document flush after a 12,000,000- or 20,000,000-document segment, or a small segment after a bulk load and
forceMerge(1)of 12 million documents followed by a near-real-time reader open. WithLogByteSizeMergePolicy, for example 50 GiB and 4 KiB segments (checked by a directfindMergescall only). Up to 9.5.0 a boundary at the minimum merge size also split levels whenever the largest segment was above it, so a 2,000-document segment and a 1-document flush were enough. - Effect on main and 10.2.0+. With the default
IndexWriterConfig, that writer's commits, near-real-time reader opens and flushes of new documents throw at once (2-5 ms) until the factor is lowered. The writer stays open; in the probes the last commit was intact (CheckIndex clean) and a committed delete stayed hidden; callingsetMergeFactor(10)on the live policy makes the next commit persist every document.close()under the setting throws, closes the writer and loses the uncommitted documents. With the merge-on-commit/refresh waitmaxFullFlushMergeWaitMillisset to 0 (default 500 ms), a commit or reader open without changes succeeds and a commit with changes throws only after it was written (read, not run). - Effect on 9.5.0-10.1.0 (run) and, by code reading, 9.3.x-9.4.x.
OutOfMemoryError: Requested array size exceeds VM limit: one rejected allocation, not heap exhaustion, butIndexWritertreats it as tragic, so the writer closes and uncommitted documents are lost; the last commit stays intact. With-XX:+ExitOnOutOfMemoryErrorthe JVM exits insidecommit()(run on 9.12.3). On 5.5.5-8.11.4 the same error left the writer open. By reading, it became tragic in 9.3.0 (LUCENE-10078), which raised the default merge-on-commit wait from 0 to 500 ms, so the commit asks the policy inside its tragic-error handler; on 9.0-9.2 it asks after the commit was written, as on 8.11.4 (9.0-9.4 not run). - How likely. Rare: a non-default policy, a boundary value and, since 9.6.0, a 1e7 size ratio. No report was found in 19 years.
The test shows that LogDocMergePolicy.findMerges with mergeFactor = Integer.MAX_VALUE (accepted by setMergeFactor) throws on two segments of 20,000,000 and 1 docs, because 'start + mergeFactor' wraps negative for the second size level.
/*
* 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.index;
import java.util.Map;
import java.util.Set;
import org.apache.lucene.codecs.Codec;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.index.BaseMergePolicyTestCase.MockMergeContext;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.StringHelper;
import org.apache.lucene.util.Version;
public class TestLogMergePolicyMaxMergeFactor extends LuceneTestCase {
private static SegmentCommitInfo segment(Directory dir, String name, int maxDoc) {
SegmentInfo si =
new SegmentInfo(
dir,
Version.LATEST,
Version.LATEST,
name,
maxDoc,
false,
false,
Codec.getDefault(),
Map.of(),
StringHelper.randomId(),
Map.of(),
null);
si.setFiles(Set.of());
return new SegmentCommitInfo(si, 0, 0, -1, -1, -1, StringHelper.randomId());
}
public void testFindMergesWithMaxMergeFactor() throws Exception {
LogDocMergePolicy mp = new LogDocMergePolicy();
mp.setMergeFactor(Integer.MAX_VALUE);
try (Directory dir = new ByteBuffersDirectory()) {
SegmentInfos infos = new SegmentInfos(Version.LATEST.major);
infos.add(segment(dir, "_0", 20_000_000));
infos.add(segment(dir, "_1", 1));
try {
assertNull(mp.findMerges(MergeTrigger.FULL_FLUSH, infos, new MockMergeContext(s -> 0)));
} catch (RuntimeException e) {
// Expected: null, no merge (as with Integer.MAX_VALUE - 1); actual: findMerges throws.
throw new AssertionError("expected findMerges to return null, but it threw " + e, e);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-394/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.index.TestLogMergePolicyMaxMergeFactor
- Expected vs actual
- Expected: null (no merge, as with Integer.MAX_VALUE - 1). Actual (-ea and -da): IndexOutOfBoundsException: Index -2147483648 out of bounds for length 2 at LogMergePolicy.findMerges:636.
More files, environment, full output61 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected findMerges to return null, but it threw java.lang.IndexOutOfBoundsException: Index -2147483648 out of bounds for length 2
org.apache.lucene.index.TestLogMergePolicyMaxMergeFactor.testFindMergesWithMaxMergeFactor(TestLogMergePolicyMaxMergeFactor.java:61)
Caused by: java.lang.IndexOutOfBoundsException: Index -2147483648 out of bounds for length 2
java.base/jdk.internal.util.Preconditions.outOfBounds(Preconditions.java:100)
java.base/jdk.internal.util.Preconditions.outOfBoundsCheckIndex(Preconditions.java:106)
java.base/jdk.internal.util.Preconditions.checkIndex(Preconditions.java:302)
java.base/java.util.Objects.checkIndex(Objects.java:365)
java.base/java.util.ArrayList.get(ArrayList.java:428)
[...]setMergeFactorrejects only values below 2, states no upper bound and recommends large values for batch indexing; on the same segmentsInteger.MAX_VALUE - 1returns no merge.- LUCENE-2331, which added
NoMergePolicy, namesmergeFactor = Int.MAX_VALas a working way to disable merges; the value was never deprecated. - The failure is an overflow, not a check:
levels.getwith a negative index.
The full argument203 words
- The setter accepts it.
setMergeFactor(LogMergePolicy.java:102-112) throws only for values below 2 ("mergeFactor cannot be less than 2") and says "larger values ({@code > 10}) are best for batch index creation". No javadoc, CHANGES or MIGRATE entry states an upper bound. Integer.MAX_VALUEmeans "unbounded" in this very class:DEFAULT_MAX_MERGE_DOCS = Integer.MAX_VALUE(:57).getMergeFactorsays the factor "controls the total number of segments allowed to accumulate in the index" (:94-97).- The expected answer is clear.
findMergesreturns the merges that are "now necessary" (MergePolicy.java:602-610), nothing documents an exception for a valid setting, andInteger.MAX_VALUE - 1and 10 return no merge on the same segments. - Tests use only small factors: random 2-9 and 10-50 in the test framework, fixed values from 2 to 1,000, or 1 + the segment count; none near
Integer.MAX_VALUE, and none expects an exception. - History. The overflow came with the class in 2007; later commits changed only the exception (LUCENE-3084, LUCENE-6892, #14166) and the trigger (#12113), without discussing large factors.
- Precedent. In the sibling
TieredMergePolicy, the byte-size setterssetMaxMergedSegmentMBandsetFloorSegmentMBsaturate the double-to-long conversion instead of overflowing (TieredMergePolicy.java:107,:173); its integer setters are not clamped.
The devil's-advocate check192 words
The strongest case for "intended" or "misuse" (built by a dedicated checker; the works-as-designed reviewer from another model family rated it moderate): NoMergePolicy is the documented way to stop merging ("Use it if you want to prevent segment merges"), added by LUCENE-2331 as the clean alternative to a high mergeFactor (its CHANGES entry: disable merges "without tweaking a particular MergePolicy parameters, such as mergeFactor"); a factor of 2^31 has no useful meaning as "segments merged at once"; MergePolicy is @lucene.experimental; and LogMergePolicy is not the default.
Why it fails: the setter accepts the value and its javadoc steers batch users to large values; LUCENE-2331 itself says NoMergePolicy "can REALLY allow you disable merges (except for maybe set mergeFactor to Int.MAX_VAL)", treating the value as working, and it was never deprecated or rejected; "experimental" allows API changes, not an IndexOutOfBoundsException from an accepted setting; and Integer.MAX_VALUE - 1 behaves correctly on the same data, so the failure is an arithmetic accident, not a limit. Verdict: not intended, a defect. Expect "use NoMergePolicy" as the first reply anyway, and offer both fixes; maintainers may reasonably prefer to reject such values instead of clamping.
Recommended Clamp the window end with MathUtil.unsignedMin
Clamp the window end instead of letting it overflow: int end = MathUtil.unsignedMin(start + mergeFactor, Integer.MAX_VALUE); at both sites, an idiom Lucene already uses for window ends. The repro then passes 3 of 3, assertions on and off, and 231 merge-related core tests pass. Alternative: bound the value in setMergeFactor.
+import org.apache.lucene.util.MathUtil;
...
// Finally, record all merges that are viable at this level:
- int end = start + mergeFactor;
+ // start >= 0 and mergeFactor >= 2: clamp instead of wrapping past Integer.MAX_VALUE
+ int end = MathUtil.unsignedMin(start + mergeFactor, Integer.MAX_VALUE);
...
start = end;
- end = start + mergeFactor;
+ end = MathUtil.unsignedMin(start + mergeFactor, Integer.MAX_VALUE);All options and trade-offs4 options ยท 710 words
One fix was built and validated in a clean scratch worktree at main a9779ef3c193; the alternatives are untested.
LogMergePolicy.java only: one import, two changed lines (:596 and :680) and a comment. start is between 0 and the segment count and mergeFactor between 2 and Integer.MAX_VALUE, so the sum read as an unsigned 32-bit value is exact; unsignedMin returns it unchanged whenever it did not overflow, and Integer.MAX_VALUE otherwise. With Integer.MAX_VALUE the window simply never fills, exactly as with Integer.MAX_VALUE - 1: no natural merge, which is what "never merge" users want. Lucene already writes window ends this way: MathUtil.unsignedMin(Integer.MAX_VALUE, windowMin + minWindowSize) in MaxScoreBulkScorer and MathUtil.unsignedMin(Integer.MAX_VALUE, offset + bitSet.length()) in BitSetDocIdStream.
+import org.apache.lucene.util.MathUtil;
...
// Finally, record all merges that are viable at this level:
- int end = start + mergeFactor;
+ // start >= 0 and mergeFactor >= 2: clamp instead of wrapping past Integer.MAX_VALUE
+ int end = MathUtil.unsignedMin(start + mergeFactor, Integer.MAX_VALUE);
...
start = end;
- end = start + mergeFactor;
+ end = MathUtil.unsignedMin(start + mergeFactor, Integer.MAX_VALUE);Trade-offs
- No behaviour change for any value that does not overflow; no allocation, one unsigned compare per window.
- Keeps accepting
Integer.MAX_VALUEas "never merge", so existing callers keep working. - Equivalent spelling without the helper:
int end = (int) Math.min((long) start + mergeFactor, Integer.MAX_VALUE);(not run). - Backport: both lines read the same on branch_10x and in the 9.x and 10.x releases (up to 10.1 the overflow reaches
new ArrayList<>(end - start)).MathUtil.unsignedMinexists only from 10.3.0, so 9.x and 10.0-10.2 need the long-arithmetic spelling or a backport of the helper (read, not run there). Add a CHANGES entry under Bug Fixes. - Open PR #16847 (more than 2^31 documents) edits
findMergesand keepsint end = start + mergeFactor, so one of the two will need a rebase. - It gives
Integer.MAX_VALUEthe meaning "never merge"; maintainers who prefer to call such values invalid would choose the setter bound instead (or both).
What the checks validated
- Clean scratch worktree at
a9779ef3c193, gradle, seedDEADBEEF, removed afterwards. Stock: the repro fails 2 of 3 with assertions on and off. With the fix: 3 of 3 pass. - With assertions on: the repro plus
TestLogMergePolicy(13 tests),TestIndexWriterMergePolicy(21),TestIndexWriterMerging(13) andTestForceMergeForever(1): 51 tests, 0 failures, 4 skipped. With assertions off: the repro plus every test matchingorg.apache.lucene.index.*Merg*: 52 classes, 231 tests, 0 failures, 8 skipped. With-Ptests.nightly=true:TestLogMergePolicy13 of 13 andTestIndexWriterMergePolicy19 of 21 (2 skipped). checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestoflucene/corepass with the fix and the test in place.- Earlier, the same fix shadowed onto the stock jars: the finder's repro 3 of 3 and the independent reproducer's 5 of 5 pass (main
ea57360912ed); ona9779ef3c193the diagram probe gets no merge for every size and both commits succeed.
Reject values above some limit with an IllegalArgumentException, or clamp them when they are set (untested).
Trade-offs
Turns a delayed commit failure into an immediate, clear error, but rejecting breaks code that passes Integer.MAX_VALUE today to mean "never merge" (LUCENE-2331's idiom) and needs a MIGRATE note; the safe bound depends on the segment count, which the setter does not know. Clamping on set (for example to Integer.MAX_VALUE / 2) avoids the break but changes what getMergeFactor returns. Can be combined with the clamp.
Point users who want to stop merges to NoMergePolicy in the setMergeFactor javadoc.
Trade-offs
Useful in addition, but an accepted value still breaks commits.
Use NoMergePolicy to stop merges (available from 3.1), or a large but not extreme factor such as 1,000,000,000 (passed the audit scenario). On main and 10.2.0+, a writer that is already failing recovers when setMergeFactor(10) (any sane value) is called on its live policy before the next commit; do not close it under the bad setting. On 9.5.0-10.1.0 (by reading also 9.3-9.4) the first failure already closed the writer: open a new writer with a sane factor on the intact last commit and add the uncommitted documents again.
Trade-offs
Needs the user to know about the bug.
Software that passes a user-supplied merge factor to LogMergePolicy should bound it: on Lucene 9.5.0-10.1.0 the failure is an OutOfMemoryError that closes the writer, and under -XX:+ExitOnOutOfMemoryError it ends the JVM (run on 9.12.3).
A scan of Lucene's compiled code for int additions of two variables that feed a loop comparison flagged start + mergeFactor in findMerges. An SMT solver (Z3, cross-checked with cvc5) found an overflowing input and proved that the wrapped end is negative and passes both loop tests; code reading and a JUnit test showed that it reaches levels.get.
The method in full202 words
Method: a bytecode inventory plus SMT proofs (the study's "formal" tier).
- An abstract interpreter over the class files (written with the JDK 25
java.lang.classfileAPI) listed everyiaddof two non-constantints whose result flows into a loop comparison. One row wasLogMergePolicy.findMerges add-var L596 -> cmp@597. - The hunting AI tool wrote three bit-vector obligations for Z3, cross-checked with cvc5: LM1 asks whether
start + mergeFactorcan wrap withstart >= 1andmergeFactor >= 2(counterexample: yes); LM1s proves the sum is exact whenevermergeFactor <= Integer.MAX_VALUE - start; LM2 proves that in the wrapped caseendis negative, both loop tests (end <= 1 + upto,end < 1 + upto) hold andend - start >= mergeFactor. The rest of the packing test (minMergeSize < maxMergeSize,mergeSize < minMergeSize, no merging segment) is not modelled: it holds for the default settings by reading the code and in every executed run. - The same AI tool then wrote a JUnit test on the stock jars (a direct
findMergescall and an end-to-endIndexWriterrun), which failed on main with assertions on and off, on 10.5.2 and, withOutOfMemoryError, on 9.12.3.
No prior report found (searched 2026-10-26, re-checked 2026-10-28) in GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream trackers and the web. Closest: LUCENE-2331 (#3407), which names this value as a way to disable merges and assumes it works.
Searches6 searches
13 related items were recorded, all different bugs. Context: LUCENE-2331 (#3407, the idiom; added NoMergePolicy), #14166 (added the packing loop that now throws), #12113 (removed the floor boundary), the open PR #16847 (the only open PR touching the file; keeps the expression) and LUCENE-6297 (a withdrawn overflow report on a different LogByteSizeMergePolicy parameter).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-26 | three prior-report tool runs (identifiers; symptoms such as "Index -2147483648 out of bounds", "Requested array size exceeds VM limit", "segments must include at least one segment"; mergeFactor MAX_VALUE wording), plus the finder's, the devil's-advocate checker's (about 20) and the adjudication's manual queries | no report; LUCENE-2331 (#3407) as context |
| GitHub issue/PR search (apache/lucene), re-check | 2026-10-28 | mergeFactor Integer.MAX_VALUE; LogMergePolicy IndexOutOfBoundsException; mergeFactor overflow; setMergeFactor MAX_VALUE | 0 hits each (raw output with a control query that returns hits: an unpublished file) |
| Open PRs and branches | 2026-10-26, 2026-10-28 | changed-file lists of all 410 open PRs; the file's commit history on main, branch_10x and branch_10_6 | only #16847 touches the file and keeps the expression; not fixed on main (last change #15295, 2026-01-23), branch_10x or branch_10_6 |
| JIRA archive (LUCENE, SOLR) | 2026-10-26 | 20 manual queries plus the tool's queries; the devil's-advocate checker's 24 list/JIRA queries (234 hits) | no report; LUCENE-6297 is a different parameter |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-26 | the tool's list queries plus 10 manual dev@/java-user@ queries | no report |
| Downstream trackers and the web | 2026-10-26 | 9 downstream GitHub queries and 16 web queries | no report; one unrelated settings-parsing bug of the same downstream setting |
As old as LogMergePolicy: introduced in 2.3.0 by commit 299d6357dd29 (LUCENE-847, 2007). All 19 releases run, from 2.3.0 to 10.5.2, and main fail; 2.2.0 passes. Later changes altered only the exception (#14166 in 10.2.0) and the trigger (#12113 in 9.6.0).
Versions tested11 rows
Introduced by commit 299d6357dd29 (2007-09-18, SVN r576798, Michael McCandless), "LUCENE-845, LUCENE-847, LUCENE-870: factor MergePolicy & MergeScheduler out of IndexWriter" (GitHub #1922, #1920, #1945; fix version 2.3; no PR). It created LogMergePolicy with both start + mergeFactor sites and the < 2-only check. Blame on main credits a 2010 svn copy (675597141b7f, LUCENE-2326), a history artifact. Not a regression.
Later changes: LUCENE-3084 (3.2, 4d48083664f) and LUCENE-6892 (5.4.0, a4b4c75dcfd) changed the exception; #12113 (9.6.0, 2d157bd3484 on main, ba32642e66a on branch_9x) removed the boundary at the level floor, so the trigger needs a 1e7 size ratio; #14166 (10.2.0, dd76dc4973f on main, 0debeed5cf5 on branch_10x) added the packing loop that now throws IndexOutOfBoundsException. By code reading, the OutOfMemoryError became tragic for the writer in 9.3.0 (LUCENE-10078 raised the default merge-on-commit wait from 0 to 500 ms, moving the merge check inside commit's tragic-error handler); 9.3-9.4 were not run and it was not bisected. The 19 executed releases are a sample (not run, among others: most 4.x, 5.0-5.4, 9.0-9.4, 9.7-9.11, 10.0.0, 10.3-10.4); both sites are unchanged in all 141 release tags from 2.3.0 to 10.5.2.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193, ea57360912ed | affected | IndexOutOfBoundsException from commit, NRT open and flush; writer stays open; lowering the factor recovers; assertions on and off |
| 10.2.0, 10.5.2 | affected | IndexOutOfBoundsException (findMerges:646); writer stays open; close() throws |
| 9.6.0, 9.12.3, 10.1.0 | affected | OutOfMemoryError "Requested array size exceeds VM limit" with 20,000,000 + 1 docs; tragic: the writer closes and the uncommitted document is lost |
| 9.6.0 | small trigger | 2,000 + 1 docs pass (#12113 removed the floor boundary) |
| 9.5.0 | affected | OutOfMemoryError with only 2,000 + 1 docs; tragic |
| 5.5.5, 6.6.6, 7.7.3, 8.11.4 | affected | OutOfMemoryError with 2,000 + 1 docs; the writer stays open |
| 3.2.0, 3.6.2, 4.10.4 | affected | RuntimeException "segments must include at least one segment" |
| 2.3.0, 2.3.2, 2.4.1, 2.9.4, 3.0.3, 3.1.0 | affected | IllegalArgumentException "fromIndex(1) > toIndex(-2147483648)"; up to 2.9.4 with the default policy |
| 2.2.0 | not affected | no MergePolicy classes; the same probe passes |
Integer.MAX_VALUE - 1 on 2.3.0 and main | control | passes on the same segments |
fix, scratch worktree at a9779ef3c193 | not affected | repro 3 of 3 and 231 merge-related core tests pass |
An independent reproducer's own tests, a works-as-designed review by another model family (OpenAI GPT-5.6, reading only), a devil's-advocate check, an adjudication and a credibility audit all found it real; the refute-lens cross-model review produced no verdict (endpoint unavailable). This doc re-ran the repro and validated the fix on main a9779ef3c193.
Checks and verdicts273 words
The checks ran on main ea57360912ed unless stated; LogMergePolicy.java is identical on a9779ef3c193.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Bytecode inventory and Z3/cvc5 obligations; the JUnit fails 2 of 3 on main with assertions on and off, on 10.5.2 and (OutOfMemoryError) on 9.12.3; the clamp passes 3 of 3. |
| cross-model review (refute lens) | not obtained | An availability failure, not a refutation. |
| cross-model review (works-as-designed lens) | real (medium) | OpenAI GPT-5.6, reading only: inputs in contract, root cause agrees, the intended argument is moderate and fails because the setter accepts the value. Separate re-runs fail 2 of 3. |
| independent reproducer | real | Own test fails 3 of 5 on main (on and off), 10.5.2 and 9.12.3, controls pass; found the Integer.MAX_VALUE - 1 case at position 2, the writer's state after the failure, and the LogByteSizeMergePolicy case (direct call). |
| devil's advocate | real | "Use NoMergePolicy" fails (LUCENE-2331 treats the value as working; never deprecated; TieredMergePolicy clamps); its probe fails on main, 10.5.2 and 9.12.3, and with 2,000 + 1 docs on 9.5.0 and 8.11.4. |
| adjudication | confirmed (high) | Re-ran the reproducer's test: 3 of 5 fail on and off, 0 with the fix. Impact class crash, not "index unusable". |
| credibility audit | keep | Re-ran the finder's test on fresh seeds; own probe: a 12,000,000-document bulk load, forceMerge(1), 1 document and an NRT open fail; 12,000,000 + 2 and 1e9 do not. |
| this doc (a9779ef3c193) | reproduced, fix validated | gradle in a scratch worktree: 2 of 3 fail on stock with assertions on and off; the fix passes the repro and 231 merge-related tests. |
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 462 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,335 words
- Registry title
- LogMergePolicy.findMerges wraps 'start + mergeFactor' when mergeFactor is Integer.MAX_VALUE (or within about the segment count of it), which setMergeFactor accepts, so commit and flush throw once two size levels exist (every release since 2.3.0)
- Area
- index / merge policy (LogMergePolicy.findMerges level window; LogDocMergePolicy, LogByteSizeMergePolicy)
- Priority
- Low, rank 389: With the real 'never merge' idiom mergeFactor=Integer.MAX_VALUE on a non-default LogMergePolicy, core IndexWriter commits fail loudly (a tragic OOM on 9.5-10.1), but it is recoverable and depends on a boundary value.
- Impact class
- crash
- Impact note
- Applications that opt into LogDocMergePolicy or LogByteSizeMergePolicy and set mergeFactor to Integer.MAX_VALUE (for example to mean 'never merge' during a bulk load) get an exception from every IndexWriter.commit(), NRT reader open and flush of new documents once a later segment is about 1e7 times smaller than an earlier one: on main and 10.2.0+ an IndexOutOfBoundsException, with the writer still open, the last commit intact and full recovery by lowering the factor (but close() under the setting throws and loses uncommitted documents); on 9.5.0-10.1.0 a tragic OutOfMemoryError that closes the writer and loses uncommitted documents; before 9.6.0 a much smaller size gap (2000 + 1 documents) is enough; not the default merge policy, no extreme data scale (20 million empty documents, a few seconds).
- Summary
- You configure LogDocMergePolicy (or LogByteSizeMergePolicy) with setMergeFactor(Integer.MAX_VALUE), which the setter accepts (it rejects only values below 2, and its javadoc recommends large values for batch indexing), bulk-load one large segment, add one document and commit, and expect the commit to succeed with no merge, as it does with Integer.MAX_VALUE - 1. Instead findMerges computes the end of each merge window as 'int end = start + mergeFactor', which wraps negative for every size level that does not start at segment 0, and its minimum-size packing step then calls levels.get(-2147483648), so commit() throws IndexOutOfBoundsException (OutOfMemoryError 'Requested array size exceeds VM limit' before 10.2.0). Every later commit and flush of new documents fails the same way until the factor is lowered; on main a 20,000,000-document segment followed by a 1-document flush is enough.
- Introduced
- 299d6357dd296f87a099fadd0764292b28dbde47 (SVN r576798, by GitHub mikemccand): 'LUCENE-845, LUCENE-847, LUCENE-870: factor MergePolicy & MergeScheduler out of IndexWriter' (JIRA LUCENE-847 'Factor merge policy out of IndexWriter', GitHub issue #1922, with LUCENE-845 #1920 and LUCENE-870 #1945); SVN era, no PR. It created LogMergePolicy with both wrapping sites ('int end = start + mergeFactor' and 'end = start + mergeFactor', today LogMergePolicy.java:596 and :680) and the '< 2'-only check in setMergeFactor [read: git blame at the 2.3.0 and 3.0.1 tags and the oldest git log -S hit; blame on main credits the 2010 LUCENE-2326 svn copy 675597141b7f, a history artifact] [exec: the same IndexWriter-only probe passes on 2.2.0 and fails on 2.3.0]. Not a regression: later commits changed only the symptom (LUCENE-3084 in 3.2, LUCENE-6892 in 5.4.0, #14166 in 10.2.0) and the reachability (#12113 in 9.6.0) (2007-09-18)
- First affected version
- 2.3.0 [exec] (2.2.0 has no MergePolicy/LogMergePolicy classes; its IndexWriter checks numSegments < mergeFactor before any window arithmetic, and the same probe passes on 2.2.0 [exec])
- Affected versions
- every release from 2.3.0 to 10.6.0 (10.6.0 by reading) and main ea57360912ed. [exec, all fail]: 2.3.0, 2.3.2, 2.4.1, 2.9.4, 3.0.3, 3.1.0, 3.2.0, 3.6.2, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.5.0, 9.6.0, 9.12.3, 10.1.0, 10.2.0, 10.5.2 and main (-ea and -da); controls pass (2.2.0 with the same probe; Integer.MAX_VALUE - 1 on 2.3.0 and main). [read]: both wrapping sites and the '< 2'-only validation are unchanged in all 141 release tags 2.3.0-10.5.2 of the history clone, and on upstream main, branch_10x and branch_10_6 (2026-10-26). Symptom by era [exec]: 2.3.0-3.1 IllegalArgumentException 'fromIndex(1) > toIndex(-2147483648)'; from 3.2 (LUCENE-3084) RuntimeException 'segments must include at least one segment'; from 5.4.0 (LUCENE-6892) OutOfMemoryError 'Requested array size exceeds VM limit' (writer stays open through 8.11.4, tragic in 9.5.0-10.1.0; the change in between was not bisected); from 10.2.0 (#14166) IndexOutOfBoundsException. Trigger [exec]: up to 9.5.0 any segment at or above the level floor followed by a smaller one (2000 + 1 docs); from 9.6.0 (#12113) a size ratio of about 1e7 (2000 + 1 passes, 20,000,000 + 1 fails). Not executed: 5.0-5.4, 10.0.0, LogByteSizeMergePolicy end to end after 2.x (direct findMerges call only), x86
- 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/cvc5
- Upstream and prior reports
- none found (2026-10-26; follow-up searches plus the finder's, the intended-behaviour check's and the adjudication's; the prior-report record verdict new; all read-only): three scripted prior-report search specs (identifiers, symptoms, the pre-10.2 symptom plus downstream wording) over GitHub issues and PRs, commit messages, Elasticsearch/OpenSearch/Solr trackers, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web (790, 443 and 2,491 distinct hits screened); 20 manual LUCENE and SOLR JIRA queries; 10 dev@/java-user@ queries; 9 downstream GitHub queries; the changed-file lists of all 410 open apache/lucene PRs; the file's commit history on main, branch_10x and branch_10_6; 16 web queries; 11 PR and issue discussions read in full; the intended-behaviour check added 24 list/JIRA queries (234 hits) and about 20 GitHub issue queries. 13 items recorded, all same_bug false. Closest, not this bug: LUCENE-2331 (#3407, 2010) names 'set mergeFactor to Int.MAX_VAL' as a way to disable merges and assumes it works (NoMergePolicy was added then); #14166 (2025) changed the symptom to IndexOutOfBoundsException without discussing large factors; #16847 (open, the only open PR touching LogMergePolicy.java) keeps the expression; LUCENE-6297 is a withdrawn LogByteSizeMergePolicy default-overflow report. Not fixed on upstream main, branch_10x or branch_10_6. Gaps: GitHub's search rate limit was saturated for parts of the run; keyword searches cannot prove absence; private security@ reports are invisible
- Lucene commit tested
- main
a9779ef3c193(2026-10-26): gradle in a clean scratch worktree with assertions on and off, stock and with the fix (repro, the closest merge suites, nightly, format and lint checks); the diagram probe on the same commit (size sweep, end to end, recovery). Earlier: mainea57360912edand the release jars of 19 versions from 2.3.0 to 10.5.2 (all fail) and 2.2.0 (passes).LogMergePolicy.javais the same on upstream main20ced71c1e6e(2026-10-27, read).
- Finding record
lucene/core/src/java/org/apache/lucene/index/LogMergePolicy.java:102-112 (setMergeFactor javadoc 'larger values (> 10) are best for batch index creation'; only check mergeFactor < 2), 596 ('int end = start + mergeFactor'), 597 ('while (end <= 1 + upto)'), 629-636 (#14166 packing branch, levels.get(end)), 680 ('end = start + mergeFactor')
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/LogMergePolicy.java:48, 102-112, 495, 510-511, 557-585, 596-597, 601, 629-636, 654-661, 679-683 (at a9779ef3c193)lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:602-611, 689-693a Lucene clone: git show releases/lucene/9.5.0:lucene/core/src/java/org/apache/lucene/index/LogMergePolicy.java (lines 553-561, the floor boundary removed by #12113)
- Who is affected
lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:143 (default TieredMergePolicy)a Lucene clone: IndexWriterConfig.java:108 at releases/lucene/9.0.0, 9.2.0 (0), 9.3.0, 9.5.0 (500); IndexWriter.java at 9.2.0 (:3875-3876, merge check after finishCommit) and 9.3.0 (:3485-3508, merge check inside the VirtualMachineError handler); git tag --contains 7d9de759f24 (LUCENE-10078, #921) starts at 9.3.0lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:108, 485-493; IndexWriter.java:619, 704-706, 3858, 3912-3913, 4265-4266 (merge-on-commit/refresh paths)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/LogMergePolicy.java:53-57, 94-112lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:69, 602-611lucene/core/src/java/org/apache/lucene/index/NoMergePolicy.java:23-26; lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:107, 173; lucene/CHANGES.txt:17968-17971 (LUCENE-2331 entry)lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:1842, 1844a Lucene main checkout: grep of setMergeFactor(<literal>) in the src/test trees (2 x19, 3 x8, 10 x6, 5 x4, 100 x4, 4 x3, 1000 x1)
- Suggested fix
lucene/core/src/java/org/apache/lucene/util/MathUtil.java:196-199; lucene/core/src/java/org/apache/lucene/search/MaxScoreBulkScorer.java:424; lucene/core/src/java/org/apache/lucene/search/BitSetDocIdStream.java:34
- History
a Lucene clone: git tag --contains 0debeed5cf5 (10.2.0 on), ba32642e66a (9.6.0 on); 7d9de759f24 (LUCENE-10078, 9.3.0 on)
- TestLogMergePolicyMaxMergeFactor.java (JUnit test):
repro/LFV-394/src/org/apache/lucene/index/TestLogMergePolicyMaxMergeFactor.java(65 lines)
Made in parallel from an executed probe on main a9779ef3c193 (the int arithmetic, direct findMerges calls, a size sweep and the end-to-end commit, recovery and fix runs); every value on screen comes from the code or the probe output. The PNG is the final summary frame.
assets/LFV-394/bug.png(462 KB)
