All findings · Lucene finding · LFV-394 · logmergepolicy-mergefactor-window-int-overflow

LogMergePolicy with mergeFactor = Integer.MAX_VALUE makes commits throw once two size levels exist

LogMergePolicy.setMergeFactor(Integer.MAX_VALUE) is accepted, but once the newest segment is about 10 million times smaller than the largest, the window end start + mergeFactor overflows to a negative int and, with default settings, every commit() throws IndexOutOfBoundsException until the factor is lowered. Every release since 2.3.0 has the overflow; on 9.5.0-10.1.0 it is an OutOfMemoryError that closes the writer.

Summary

Priority
Low, rank 389With 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
crash
Since
2.3.0 (2007, commit 299d6357dd29); not a regressionintroduced 2007-09-18 · over 19 years
Affected
every release from 2.3.0 to 10.6.0 (10.6.0 by reading) and main; opt-in LogMergePolicy only
Status
documented
Found by
a bytecode scan of int arithmetic plus SMT proofs (Z3, cross-checked with cvc5)

AI-assisted page: this finding was first signalled by a formal tool's own output and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Diagram of the bug: LogDocMergePolicy with mergeFactor = Integer.MAX_VALUE; a 20,000,000-document segment and a 1-document segment form two size levels; for the second level, which starts at position 1, the window end 1 + 2,147,483,647 overflows to -2,147,483,648, the loop test passes, and levels.get(-2147483648) throws IndexOutOfBoundsException from IndexWriter.commit(); with Integer.MAX_VALUE - 1 the same commit stores 20,000,001 documents.
Two segments, 20,000,000 and 1 documents, fall into two size levels; for the second level the window end 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.
  1. Use LogDocMergePolicy with setMergeFactor(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.
  2. Add 1 document and commit. You expect 2 segments and 20,000,001 documents, which is what Integer.MAX_VALUE - 1 gives on the same data.
  3. 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 end start + mergeFactor = 1 + 2,147,483,647 does not fit in an int and becomes -2,147,483,648.
  4. Nothing rejects the negative end: the loop test end <= 1 + upto passes and the code reads levels.get(-2147483648). commit() throws IndexOutOfBoundsException: Index -2147483648 out of bounds for length 2; the last commit still has 20,000,000 documents.
  5. 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 an OutOfMemoryError that closes the writer; up to 9.5.0, 2,000 + 1 documents are enough.

Who is affected

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.

Terms

  • Size level: LogMergePolicy gives each segment the level log(size) / log(mergeFactor) (size = documents for LogDocMergePolicy, bytes for LogByteSizeMergePolicy). A level runs from its first segment to the last segment whose level is within LEVEL_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, findMerges looks at runs of mergeFactor segments, from start to end = start + mergeFactor, and merges a run once it is full. start is 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), ...

The bug, step by step

  1. setMergeFactor checks only mergeFactor < 2 (LogMergePolicy.java:109-112). With Integer.MAX_VALUE = 2,147,483,647, log(mergeFactor) is 21.49.
  2. 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 than mergeFactor^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.
  3. 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.
  4. while (end <= 1 + upto) (:597) is true for a negative end; the window loop for (i = start; i < end; ...) runs zero times, so the window looks empty and small; end - start wraps back to 2,147,483,647, so the packing test at :629-632 passes.
  5. The packing loop calls levels.get(end) (:636): IndexOutOfBoundsException: Index -2147483648 out of bounds for length 2. For a level that starts at position start, end is Integer.MIN_VALUE + (start - 1), so the index is -2147483647 when it starts at 2. Before 10.2.0 the same state reached new 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).
  6. IndexWriter.commit() asks the policy for merges through MergePolicy.findFullFlushMerges (MergePolicy.java:693), so the exception comes out of commit(); the same happens on near-real-time reader open and on flushes of new documents.

The numbers

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 choose LogDocMergePolicy or LogByteSizeMergePolicy and set the factor to Integer.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 calls setMergeFactor(Integer.MAX_VALUE). In 2.3.0-3.1 LogByteSizeMergePolicy was the default and IndexWriter.setMergeFactor reached 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. With LogByteSizeMergePolicy, for example 50 GiB and 4 KiB segments (checked by a direct findMerges call 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; calling setMergeFactor(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 wait maxFullFlushMergeWaitMillis set 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, but IndexWriter treats it as tragic, so the writer closes and uncommitted documents are lost; the last commit stays intact. With -XX:+ExitOnOutOfMemoryError the JVM exits inside commit() (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.

Minimal repro

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.

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

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

More files, environment, full output61 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# 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)
       [...]

Why it looks unintended

  • setMergeFactor rejects only values below 2, states no upper bound and recommends large values for batch indexing; on the same segments Integer.MAX_VALUE - 1 returns no merge.
  • LUCENE-2331, which added NoMergePolicy, names mergeFactor = Int.MAX_VAL as a working way to disable merges; the value was never deprecated.
  • The failure is an overflow, not a check: levels.get with 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_VALUE means "unbounded" in this very class: DEFAULT_MAX_MERGE_DOCS = Integer.MAX_VALUE (:57). getMergeFactor says the factor "controls the total number of segments allowed to accumulate in the index" (:94-97).
  • The expected answer is clear. findMerges returns the merges that are "now necessary" (MergePolicy.java:602-610), nothing documents an exception for a valid setting, and Integer.MAX_VALUE - 1 and 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 setters setMaxMergedSegmentMB and setFloorSegmentMB saturate 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.

Suggested fix

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.

Clamp the window end with MathUtil.unsignedMin (validated by the checks) (recommended)

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_VALUE as "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.unsignedMin exists 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 findMerges and keeps int end = start + mergeFactor, so one of the two will need a rebase.
  • It gives Integer.MAX_VALUE the 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, seed DEADBEEF, 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) and TestForceMergeForever (1): 51 tests, 0 failures, 4 skipped. With assertions off: the repro plus every test matching org.apache.lucene.index.*Merg*: 52 classes, 231 tests, 0 failures, 8 skipped. With -Ptests.nightly=true: TestLogMergePolicy 13 of 13 and TestIndexWriterMergePolicy 19 of 21 (2 skipped).
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest of lucene/core pass 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); on a9779ef3c193 the diagram probe gets no merge for every size and both commits succeed.

Bound the value in setMergeFactor

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.

Document NoMergePolicy in setMergeFactor

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.

Workaround for users today

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.

Downstream note

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).

How it was found

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.classfile API) listed every iadd of two non-constant ints whose result flows into a loop comparison. One row was LogMergePolicy.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 + mergeFactor can wrap with start >= 1 and mergeFactor >= 2 (counterexample: yes); LM1s proves the sum is exact whenever mergeFactor <= Integer.MAX_VALUE - start; LM2 proves that in the wrapped case end is negative, both loop tests (end <= 1 + upto, end < 1 + upto) hold and end - 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 findMerges call and an end-to-end IndexWriter run), which failed on main with assertions on and off, on 10.5.2 and, with OutOfMemoryError, on 9.12.3.

Prior reports, history and checks

Prior reports

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).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26three 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 queriesno report; LUCENE-2331 (#3407) as context
GitHub issue/PR search (apache/lucene), re-check2026-10-28mergeFactor Integer.MAX_VALUE; LogMergePolicy IndexOutOfBoundsException; mergeFactor overflow; setMergeFactor MAX_VALUE0 hits each (raw output with a control query that returns hits: an unpublished file)
Open PRs and branches2026-10-26, 2026-10-28changed-file lists of all 410 open PRs; the file's commit history on main, branch_10x and branch_10_6only #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-2620 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-26the tool's list queries plus 10 manual dev@/java-user@ queriesno report
Downstream trackers and the web2026-10-269 downstream GitHub queries and 16 web queriesno report; one unrelated settings-parsing bug of the same downstream setting

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193, ea57360912ed affectedIndexOutOfBoundsException from commit, NRT open and flush; writer stays open; lowering the factor recovers; assertions on and off
10.2.0, 10.5.2 affectedIndexOutOfBoundsException (findMerges:646); writer stays open; close() throws
9.6.0, 9.12.3, 10.1.0 affectedOutOfMemoryError "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 trigger2,000 + 1 docs pass (#12113 removed the floor boundary)
9.5.0 affectedOutOfMemoryError with only 2,000 + 1 docs; tragic
5.5.5, 6.6.6, 7.7.3, 8.11.4 affectedOutOfMemoryError with 2,000 + 1 docs; the writer stays open
3.2.0, 3.6.2, 4.10.4 affectedRuntimeException "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 affectedIllegalArgumentException "fromIndex(1) > toIndex(-2147483648)"; up to 2.9.4 with the default policy
2.2.0 not affectedno MergePolicy classes; the same probe passes
Integer.MAX_VALUE - 1 on 2.3.0 and main controlpasses on the same segments
fix, scratch worktree at a9779ef3c193 not affectedrepro 3 of 3 and 231 merge-related core tests pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateBytecode 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 realOwn 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 keepRe-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 validatedgradle 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,335 words

Finding record

LFV-394, snapshot of 2026-11-06

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: main ea57360912ed and the release jars of 19 versions from 2.3.0 to 10.5.2 (all fail) and 2.2.0 (passes). LogMergePolicy.java is the same on upstream main 20ced71c1e6e (2026-10-27, read).

Lucene and upstream sources

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-693
  • a 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.0
  • lucene/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-112
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:69, 602-611
  • lucene/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, 1844
  • a 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)

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

Repro files

About the diagrams

Made in parallel 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.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.