All findings · Lucene finding · LFV-442 · tmp-zero-max-merged-segment-empty-candidate

TieredMergePolicy accepts setMaxMergedSegmentMB(0), then commit(), close() and forceMergeDeletes() throw once enough documents are deleted

TieredMergePolicy.setMaxMergedSegmentMB(0) is accepted, but once enough documents are deleted, commit(), close() and forceMergeDeletes() throw: the merge-selection loop never takes a segment and then reads the first entry of an empty list. A regression since 7.5.0; a 1-byte maximum works.

Summary

Priority
Low, rank 392SetMaxMergedSegmentMB(0) on the default TieredMergePolicy is accepted, but commit, close and forceMergeDeletes later throw loudly once deletes pile up, the same degenerate-setting profile as LFV-394, and it is less reachable than LFV-454.
Impact
crash
Since
7.5.0 (LUCENE-7976, 2018)introduced 2018-06-15 · about 8 years
Affected
every release checked, 7.5.0 to 10.6.0, and main (8 releases executed, 57 tags and 10.6.0 read); 7.4.0 not affected
Status
documented
Found by
boundary-value probes of setter checks

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

A merge policy picks segments to merge in a loop that stops before the merge would grow past maxMergedSegmentMB. A segment that alone is bigger than the limit is still merged on its own, to reclaim its deletes. With a limit of 0 bytes the loop stops before it looks at any segment.

Diagram of the bug: with maxMergedSegmentMB 0 the merge-selection loop's guard bytesThisMerge < maxMergedSegmentBytes reads 0 < 0, so no segment enters the candidate list and candidate.get(0) throws from commit() and forceMergeDeletes(); with a 1-byte maximum the guard reads 0 < 1 and the segment with deletes becomes a singleton merge.
commit() and forceMergeDeletes() with a 0-byte and a 1-byte maximum: at 0 the selection loop stops before the first segment, and reading the empty candidate list throws; at 1 byte the segment becomes a one-segment merge. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Configure the default merge policy with new TieredMergePolicy().setMaxMergedSegmentMB(0). The setter accepts it: it rejects only negative values ("must be >=0").
  2. Index 10 documents into one segment, delete 5 of them, add 1 more and call commit(). You expect the commit to succeed with 6 live documents, as it does with a 1-byte or the default 5 GB maximum.
  3. Instead commit() throws IndexOutOfBoundsException: Index 0 out of bounds for length 0 (an AssertionError with assertions on). forceMergeDeletes() on two 5-document segments with 1 delete each throws the same way; so do close(), NRT reopens and indexing calls that flush, while those deletes remain.
  4. Why: the loop that picks segments for a merge runs only while bytesThisMerge < maxMergedSegmentBytes. With a 0-byte limit that is 0 < 0, false before the first segment, so even the branch meant for one oversized segment never runs and the candidate list stays empty.
  5. It needs deletes: with the defaults, more than 20% of the index and of a segment for commit(), more than 10% of a segment for forceMergeDeletes(). With the default settings the index on disk stays at the last commit; 7.4.0 accepted 0 without error.

Who is affected

Applications that set setMaxMergedSegmentMB(0), or -0.0, a positive value below 2^-20 MB or NaN, which are stored as 0 bytes; one downstream user set 0 expecting "no limit". Append-only indexing works, so it can pass testing and fail later, once updates or deletes pile up. Then commits keep failing while the deletes remain, until the merge policy is replaced.

Mechanism, terms and numbers886 words

TieredMergePolicy.setMaxMergedSegmentMB rejects only negative values, so 0 is stored as a 0-byte maximum; so are -0.0, every positive value below 2^-20 MB (one byte), and NaN. The merge-selection loop in doFindMerges runs only while bytesThisMerge < maxMergedSegmentBytes, which is 0 < 0 before the first segment is added, so the loop never admits a segment, not even through the branch that turns an oversized segment into a one-segment ("singleton") merge. The candidate list stays empty, and the next lines fail: assert candidate.size() > 0 with assertions on, candidate.get(0) with IndexOutOfBoundsException with them off. This comes out of commit(), close(), NRT reopens and segment-flushing adds once more than 20% of the index and of some segment is deleted, and out of forceMergeDeletes() once a segment has more than 10% deletes (the defaults of deletesPctAllowed and forceMergeDeletesPctAllowed). A 1-byte maximum works; 7.4.0 accepted 0 without error; every release checked from 7.5.0 on fails (8 releases and main executed, the same code read in the 57 release tags 7.5.0-10.5.2 of the history clone).

Terms

  • Merge candidate: the list of segments TieredMergePolicy considers merging together. doFindMerges builds one per start position and keeps the best-scoring one.
  • Singleton merge: a merge of one segment into a new one, which rewrites it without its deleted documents. The class javadoc describes it, "down to 1 at once, if that one has deletions" (TieredMergePolicy.java:42-44), and the selection loop states it for a segment that alone is over the maximum: "We should never have something coming in that _cannot_ be merged, so handle singleton merges" (:559-560).

The bug

  1. setMaxMergedSegmentMB (:102-109) throws only for v < 0.0 ("maxMergedSegmentMB must be >=0") and stores (long) (v * 1024 * 1024): 0, -0.0, every positive value below 2^-20 MB and NaN become maxMergedSegmentBytes = 0; negative values are rejected.
  2. With a 0 maximum, findMerges sets aside every non-empty segment as too large (> maxMergedSegmentBytes / 2) unless both the index and the segment are more than deletesPctAllowed (default 20%) deleted (:381-397), then calls doFindMerges (:438). findForcedDeletesMerges passes every segment with more than forceMergeDeletesPctAllowed (default 10%) deletes (:957-963, call at :968). Both leave out segments that are already merging.
  3. The candidate loop's header includes bytesThisMerge < maxMergedSegmentBytes (:539-548, clause at :546). bytesThisMerge starts at 0, so the header is 0 < 0, false, before the first segment. The body, which admits an oversized first segment as a singleton ("We should never have something coming in that _cannot_ be merged, so handle singleton merges", :552-570), never runs.
  4. assert candidate.size() > 0 (:578) fails with assertions on; segInfosSizes.get(candidate.get(0)) (:580) throws IndexOutOfBoundsException with them off.
  5. Callers: commit() and prepareCommit() through findFullFlushMerges (IndexWriter.java:3863 -> :2496, MergePolicy.java:693; with the default 500 ms maxFullFlushMergeWaitMillis, :3858); NRT reopens (:635, :704-706), close(), an explicit flush(), maybeMerge() and segment-flushing adds through maybeMerge (:2445 -> :2508, :6345-6360); forceMergeDeletes() (:2359), or already in its preliminary flush(true, true) (:2348) when deletes are still unflushed. The selection of forceMerge(1) (findForcedMerges, TieredMergePolicy.java:745-921) does not use doFindMerges and is not affected (executed).

A 1-byte maximum makes the header 0 < 1, so the first segment enters and becomes a singleton merge.

The numbers (main a9779ef3c193)

caseexpectedmain
0 maximum: 10 docs, delete 5, add 1, commit()6 live docsAssertionError at :578 / IndexOutOfBoundsException at :580
0 maximum: two 5-doc segments, 1 delete each, forceMergeDeletes()8 live docsthe same exception
0 maximum: 100 docs, delete 50, commit() or close()succeedsthe same exception; close() rolls back
1-byte or default maximum, the 100-doc commit() and forceMergeDeletes() casessucceedssucceeds; deletes reclaimed (maxDoc 50 after commit, 70 after forceMergeDeletes() with 30 deleted and deletesPctAllowed 50)
0 maximum and setMaxFullFlushMergeWaitMillis(0): 100 docs, delete 50, commit()succeedsthrows after the commit was written (numDocs 50 on disk)
0 maximum, append only or 6% deletessucceedssucceeds

What it does to the writer (main ea57360912ed, the same code, and an entry-point probe on a9779ef3c193): with assertions off no entry point records a tragic event and the writer stays open; calls keep failing while the delete thresholds are exceeded (a second commit after 10 more documents threw again), and replacing the merge policy on the live config (w.getConfig().setMergePolicy(new TieredMergePolicy())) recovers it. With assertions on, commit(), prepareCommit(), NRT reopens and segment-flushing adds record the AssertionError as tragic and the writer closes (IndexWriter.java:3879-3883, :711-721, :1632-1640); forceMergeDeletes(), an explicit flush() and maybeMerge() let it propagate and the writer stays open, not tragic. A close() that throws closes the writer and drops the uncommitted changes, as close() documents for any exception (:1344-1352). With the default 500 ms maxFullFlushMergeWaitMillis a failing commit() writes no commit point: the index on disk stays at the last commit and CheckIndex is clean. With setMaxFullFlushMergeWaitMillis(0) the commit is written first (:4261) and commit() then throws from the post-commit maybeMerge (:4265-4266), so the caller sees an exception for a durable commit. An addDocument whose automatic flush throws has already been indexed, so retrying it can add a duplicate.

Exposure and likelihood232 words
  • Who is hit. Applications that configure the default TieredMergePolicy with a 0 maximum, or with -0.0, a positive value below 2^-20 MB or NaN. Some people set 0 meaning "no limit": one user on a downstream forum set a 0-byte maximum expecting "unlimited" (that server maps 0 to "unset", so Lucene never saw it), and Lucene's own randomized tests were steered away from 0 in 2013 and from near-0 values in 2015.
  • Opt-in. A non-default value of a public setter on the default merge policy.
  • Delayed. Append-only indexing works; the failure starts once update or delete churn pushes the index and a segment past deletesPctAllowed (20% by default), or when forceMergeDeletes() runs on a segment with more than forceMergeDeletesPctAllowed (10% by default) deletes.
  • Effect. Commits, closes, NRT reopens and segment-flushing adds keep failing while those thresholds are exceeded. With assertions off the writer is usable again after the merge policy is replaced; with assertions on, a failing commit, NRT reopen or flushing add closes it. A throwing close() drops the uncommitted changes, as close() documents for any exception. With setMaxFullFlushMergeWaitMillis(0) a commit() that throws has already been written. No corruption of committed data, no wrong hits, no hang.
  • Scale. None: 10 documents.
  • Likelihood. Rare: no matching report was found in the searches (see Prior reports), eight years after 7.5.0, and the downstream wrappers seen mostly treat 0 as "unset" or reject it.

Minimal repro

The test shows that TieredMergePolicy.setMaxMergedSegmentMB(0), which the setter accepts, makes forceMergeDeletes() throw on a 5-doc segment with 1 deleted doc: the doFindMerges candidate loop never admits a segment, so the merge candidate is empty.

TestTieredMergePolicyZeroMaxMergedSegment.java (JUnit test)47 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 org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestTieredMergePolicyZeroMaxMergedSegment extends LuceneTestCase {

  public void testForceMergeDeletes() throws Exception {
    TieredMergePolicy tmp = new TieredMergePolicy();
    tmp.setMaxMergedSegmentMB(0); // accepted: the setter only rejects values < 0
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setMergePolicy(tmp))) {
      for (int i = 0; i < 5; i++) {
        Document doc = new Document();
        doc.add(new StringField("id", Integer.toString(i), Field.Store.NO));
        w.addDocument(doc);
      }
      w.commit();
      w.deleteDocuments(new Term("id", "0"));
      try {
        w.forceMergeDeletes();
      } catch (AssertionError | IndexOutOfBoundsException e) {
        throw new AssertionError("expected forceMergeDeletes() to succeed, got " + e, e);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-442/, 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.TestTieredMergePolicyZeroMaxMergedSegment
Expected vs actual
Expected forceMergeDeletes() to succeed; actual: AssertionError at TieredMergePolicy.java:578 (-ea) or IndexOutOfBoundsException: Index 0 out of bounds for length 0 at :580 (-da), from findForcedDeletesMerges.

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

More files, environment, full output41 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 forceMergeDeletes() to succeed, got java.lang.AssertionError
       org.apache.lucene.index.TestTieredMergePolicyZeroMaxMergedSegment.testForceMergeDeletes(TestTieredMergePolicyZeroMaxMergedSegment.java:43)
     Caused by: java.lang.AssertionError
       org.apache.lucene.index.TieredMergePolicy.doFindMerges(TieredMergePolicy.java:578)
       org.apache.lucene.index.TieredMergePolicy.findForcedDeletesMerges(TieredMergePolicy.java:968)
       org.apache.lucene.index.IndexWriter.forceMergeDeletes(IndexWriter.java:2359)
       org.apache.lucene.index.IndexWriter.forceMergeDeletes(IndexWriter.java:2425)
       org.apache.lucene.index.TestTieredMergePolicyZeroMaxMergedSegment.testForceMergeDeletes(TestTieredMergePolicyZeroMaxMergedSegment.java:41)

Why it looks unintended

  • The setter accepts 0 by its own check (must be >=0); the sibling setFloorSegmentMB rejects 0.
  • The javadoc says an oversized merge shrinks "down to 1 at once, if that one has deletions"; the code asserts the candidate is never empty.
  • A 0 maximum worked through 7.4.0 (no natural merges); no CHANGES or MIGRATE entry drops it.
The full argument299 words
  • Setter contract. if (v < 0.0) with "maxMergedSegmentMB must be >=0" (TieredMergePolicy.java:103-104); the javadoc (:97-101) gives no other range. setFloorSegmentMB checks v <= 0.0, "floorSegmentMB must be > 0.0" (:168-170): the class rejects 0 where it means to.
  • Documented handling of oversized segments. "If a merge will produce a segment that's larger than {@link #setMaxMergedSegmentMB}, then the policy will merge fewer segments (down to 1 at once, if that one has deletions) to keep the segment size under budget" (:42-44). The loop says "We should never have something coming in that _cannot_ be merged, so handle singleton merges" (:559-560), and "We should never see an empty candidate" with assert candidate.size() > 0 (:576-578).
  • Tests. TestTieredMergePolicy.testSetters (:759-793) tries 0.5, +Infinity, Long.MAX_VALUE / 1 MiB and -2.0 (rejected), not 0. The test framework randomizes setMaxMergedSegmentMB(r.nextDouble() * 100) (LuceneTestCaseParent.java:1977), a range that starts at 0 (a 0-byte draw has a probability of about 1e-8).
  • History. Through 7.4.0, findMerges set every segment aside (no natural merges) and findForcedDeletesMerges ignored the maximum ("Don't enforce max merged size here"). LUCENE-7976 (7.5.0) put both on doFindMerges and added the clause; its CHANGES entry says only "TieredMergePolicy now respects maxSegmentSizeMB by default when executing findForcedMerges and findForcedDeletesMerges" (CHANGES.txt:7224-7227). None of the 124 comments on #9025 discusses a 0 maximum. Two test-only commits keep randomized tests away from such values: one from 2013 ("don't let maxMergedSegmentMB be 0.0"; "If we get TieredMP and it's maxMergedSegmentMB is 0 then this test falsely fails") suggests 0 was then known to mean "no merging" rather than an error; one from 2015 ("do not use near-zero maxMergedSegmentMB for this test!") only shows near-0 values did not suit one test. Neither documents what 0 means.
The devil's-advocate check172 words

The strongest case for "intended": a 0-byte "maximum sized segment to produce" can never be met, and the class javadoc says "findForcedDeletesMerges should never produce segments greater than maxSegmentSize" (:61), so 0 has no valid output; users who want no merging should use NoMergePolicy; the >=0 check came with LUCENE-4323 (2012) along with a compound-file size setter where 0 does mean something; and the class is @lucene.experimental (:67).

Why it fails: that supports at most "reject 0 in the setter" or "no merge", not an internal AssertionError or IndexOutOfBoundsException out of commit(), close() or forceMergeDeletes(). A 1-byte maximum is just as impossible to meet and works; 0 worked through 7.4.0; "experimental" allows API changes, not crashes on an accepted value. The cross-model reviews rated the intended-behaviour argument weak; the independent reproducer, the devil's-advocate checker and the adjudication found the bug real without a rating. The cross-model fact check of this doc rated "0 is an invalid configuration" moderate and "the current exception is intended" weak.

Suggested fix

Recommended Always admit the first segment in the candidate loop

Let the candidate loop always look at the first segment: && (candidate.isEmpty() || bytesThisMerge < maxMergedSegmentBytes) (TieredMergePolicy.java:546). Nothing changes for a positive maximum. Validated with gradle: the repro tests pass with assertions on and off, and 10 upstream merge and delete suites pass (105 tests, 12 skipped, 0 failures).

                 && (candidate.size() < mergeFactor || bytesThisMerge < floorSegmentBytes)
-                && bytesThisMerge < maxMergedSegmentBytes
+                // always look at the first segment: the body turns it into a singleton merge even
+                // when it alone exceeds maxMergedSegmentBytes (e.g. maxMergedSegmentMB == 0)
+                && (candidate.isEmpty() || bytesThisMerge < maxMergedSegmentBytes)
                 && (bytesThisMerge < floorSegmentBytes || docCountThisMerge <= allowedDocCount);
All options and trade-offs4 options · 793 words

Two fixes were built and validated in a scratch worktree at a9779ef3c193 (removed afterwards); either is reasonable, and the maintainers choose. Workaround today: use a maximum of at least 1 byte (1.0 / 1024 / 1024 MB), or NoMergePolicy if no merging is wanted. Related but separate: LFV-447 (setSegmentsPerTier(NaN) makes the next flush hang), which these fixes do not touch.

Always admit the first segment in the candidate loop (validated by the checks) (recommended)

One clause in TieredMergePolicy.java:546. bytesThisMerge grows only when a segment is added (:571-573), so it is 0 while the candidate is empty, and 0 < max already holds for any positive maximum: the new clause differs only at 0. The first segment then reaches the existing singleton-merge branch (:552-570).

                 && (candidate.size() < mergeFactor || bytesThisMerge < floorSegmentBytes)
-                && bytesThisMerge < maxMergedSegmentBytes
+                // always look at the first segment: the body turns it into a singleton merge even
+                // when it alone exceeds maxMergedSegmentBytes (e.g. maxMergedSegmentMB == 0)
+                && (candidate.isEmpty() || bytesThisMerge < maxMergedSegmentBytes)
                 && (bytesThisMerge < floorSegmentBytes || docCountThisMerge <= allowedDocCount);

Trade-offs

  • What 0 then means (executed): forceMergeDeletes() reclaims the deletes by singleton merges (30 of 100 deleted: maxDoc 70), like 1 byte. commit() succeeds but merges nothing (50 of 100 deleted: maxDoc 100), because findMerges passes maxMergeIsRunning = mergingBytes >= maxMergedSegmentBytes (:447), which is 0 >= 0, and too-large candidates are then skipped (:626-627). That is the 7.4.0 behaviour; a 1-byte maximum does reclaim on commit (maxDoc 50).
  • A semantic choice. With this fix forceMergeDeletes() writes segments larger than a 0-byte maximum, as it already does for any maximum smaller than a deleted segment (1 byte, executed). The javadoc line "findForcedDeletesMerges should never produce segments greater than maxSegmentSize" (:61, added in 2021) is already not literal for those singleton merges, which LUCENE-7976 introduced. It is recommended here because every value that works today keeps working; if maintainers see 0 as an invalid configuration, the setter fix is at least as defensible.
  • Large indexes were not tested: when the eligible segments total at least segsPerTier x max(smallest segment, floorSegmentBytes) bytes (with the defaults 128 MB or more), the tier loop (:402-413) reaches a 0 level size; the double division gives +Infinity (NaN at exactly that size), the loop stops at levelSize == maxMergedSegmentBytes (:406), and (int) allowedSegCount (:442) becomes Integer.MAX_VALUE (0 for NaN). No exception is expected there; what the fixed selection then picks was not tested (read only).
  • Add a CHANGES entry under Bug Fixes; the clause is the same on the 10.x branches.

What the checks validated

  • Clean scratch worktree at a9779ef3c193, gradle, seed DEADBEEF. Stock: 5 of 10 repro tests fail, assertions on and off. With the fix: the 3 repro classes (10 tests) and TestTieredMergePolicy, TestIndexWriterMergePolicy, TestIndexWriterForceMerge, TestIndexWriterMerging, TestIndexWriterDelete, TestForceMergeForever, TestSoftDeletesRetentionMergePolicy, TestUpgradeIndexMergePolicy, TestOneMergeWrappingMergePolicy and TestThreadedForceMerge: 115 tests, 12 skipped, 0 failures. Assertions off: the repro classes, TestTieredMergePolicy and TestIndexWriterMergePolicy, 48 tests, 0 failures.
  • A fix-outcome check (commit and forceMergeDeletes with a 0 maximum, maxDoc and numDocs asserted) passes; checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain on lucene/core pass.

Reject values that cannot be a maximum in the setter (validated by the checks)

if ((v > 0.0) == false) throws "maxMergedSegmentMB must be > 0.0" (also for -0.0 and NaN), and Math.max(1L, (long) v) rounds tiny positive values up to 1 byte; the javadoc states the range.

if ((v > 0.0) == false) { // also rejects NaN
  throw new IllegalArgumentException("maxMergedSegmentMB must be > 0.0 (got " + v + ")");
}
v *= 1024 * 1024;
maxMergedSegmentBytes = v > Long.MAX_VALUE ? Long.MAX_VALUE : Math.max(1L, (long) v);

Trade-offs

Matches setFloorSegmentMB and fails at configuration time, not at the first heavily deleted commit. A behaviour change: applications that pass 0 and index append-only, which works today, get an IllegalArgumentException, so it needs a CHANGES (maybe MIGRATE) entry. The independent reproducer's TestTMPZeroMaxMergedV1 asserts that 0 is accepted and does not fit this option.

What the checks validated

Same worktree: the finder's test (2 of 2; it returns early), a setter check (0, -0.0 and NaN rejected; 1e-7 stored as 1 byte), TestTieredMergePolicy and TestIndexWriterMergePolicy: 42 tests, 0 failures.

Both

The loop fix keeps merge selection safe if a 0-byte maximum ever reaches it again (defence in depth: maxMergedSegmentBytes is private, so today only the setter sets it); the setter fix makes the contract explicit.

Trade-offs

Not run together; each part was validated on its own.

Treat 0 as "no limit" (not recommended)

Map 0 to Long.MAX_VALUE in the setter.

Trade-offs

Silently changes what the value means; Lucene never documented that meaning.

Downstream note

Downstream (code reading only): one search server passes a per-index byte-size setting straight to this setter with no lower bound, so a 0-byte index setting would reach this crash there; another treats 0 as "unset".

How it was found

A static inventory of the argument checks in Lucene's public setters gave the values each one accepts at its boundary. Driving selected numeric setters of four merge policies end to end with those values (272 configurations) flagged an exception for every maxMergedSegmentMB that maps to 0 bytes; a hand-written JUnit confirmed it on the commit and forceMergeDeletes paths.

The method in full153 words

Method: boundary-value testing guided by a static guard inventory (semi-formal; no model checker or prover was involved).

  • A bytecode pass over 27 core and non-sandbox module jars listed 1,231 public entry points that can throw IllegalArgumentException, with each guard's parameters and constants.
  • From the guard constants it derived boundary values and probed them on the real jar: setMaxMergedSegmentMB accepts 0, -0.0, Double.MIN_VALUE, 1e-7 and NaN.
  • A sweep drove selected numeric setters of four merge policies (TieredMergePolicy, LogByteSizeMergePolicy, LogDocMergePolicy, TemporalMergePolicy) end to end with a watchdog (272 configurations) and flagged a thrown IndexOutOfBoundsException for every maxMergedSegmentMB value that maps to 0 bytes; 1.0 and larger were clean. Its workload deletes while indexing and calls commit() before forceMergeDeletes(), and the first exception ends a run, so with 0 the sweep did not get to forceMergeDeletes() on its own (from reading the workload); the finder's public-API JUnit then showed that path as well.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26, re-checked 2026-10-28) in Lucene's GitHub issues, PRs and commits, JIRA, the mailing lists, downstream trackers or the web. Nearest: LUCENE-7976 (#9025), which added the loop clause, and two test-only commits that keep randomized tests away from a 0 maximum.

Searches3 searches

Verdict new: 26 hits, none the same bug. Of the 411 open PRs, 6 touch TieredMergePolicy*.java and none changes the setter or the clause. Other context: #9310 / LUCENE-8263 (widened the commit path), #5390 / LUCENE-4323 (added the >=0 check), #8062 / LUCENE-7005 (tightened only setFloorSegmentMB's message). Gaps: GitHub's search rate limit (6 queries re-run through the tool), punctuation-insensitive search, list bodies cached to 400 characters, private reports invisible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA archive; downstream repositories; web2026-10-26the hunt's prior-report tool, four runs: setMaxMergedSegmentMB, doFindMerges, maxMergedSegmentBytes, findForcedDeletesMerges, "Index 0 out of bounds for length 0", candidate.size() > 0, "must be >=0", zero, NaN, forceMergeDeletes, expungeDeletesno report; nearest #9025 / LUCENE-7976 and the test-only commits db140ffc83a, fd44b39c03b
GitHub-wide, JIRA (LUCENE, SOLR), Solr lists, open PRs, history2026-10-268 GitHub-wide searches, 6 JIRA REST queries, 9 list queries, the file lists of all 411 open PRs, all 124 comments of #9025, git log -S/--grep, CHANGES.txt and MIGRATE.mdno report; a downstream forum user set a 0-byte maximum expecting "unlimited" (that server maps 0 to "unset")
GitHub issues and PRs (apache/lucene); the file's upstream commit list2026-10-28setMaxMergedSegmentMB; maxMergedSegmentMB 0; TieredMergePolicy IndexOutOfBoundsException; doFindMerges; open PRs TieredMergePolicynothing new; the file last changed in 6643a5dbe1ed (2026-01-23), before a9779ef3c193

History

Introduced by LUCENE-7976 (2519025fdaf, 2018-06-15) and widened to the commit path by LUCENE-8263 (2018-07-17); both first shipped in 7.5.0. 7.4.0 is the last unaffected release; every release checked from 7.5.0 to 10.5.2, and main, fails (8 releases executed, 57 tags read).

Versions tested7 rows

Introduced by LUCENE-7976 (#9025; patches on the issue, no PR), "Make TieredMergePolicy respect maxSegmentSizeMB and allow singleton merges of very large segments", Erick Erickson, 2018-06-15: 2519025fdaf on master, 9c4e315c1cb on branch_7x (the copy in 7.5.0). It moved forced-deletes and natural selection onto one doFindMerges and added bytesThisMerge < maxMergedSegmentBytes to its loop. Shadowing that commit's file onto the 7.4.0 jars makes forceMergeDeletes() fail; the parent's file passes.

Widened by LUCENE-8263 (8093c450c1f / 8875402694b, #9310, Adrien Grand, 2018-07-17): the commit path now reaches the loop at deletesPctAllowed instead of a hard-coded 50% (per-commit shadow bisect over the nine branch_7x commits between them).

With assertions off the throw site is score() in 7.5.0 to 9.2.x (executed on 7.5.0, 8.11.4 and 9.0.0; read for 9.1.0 and 9.2.0) and candidate.get(0) in doFindMerges from 9.3.0 on (LUCENE-10574); the outcome is the same. All 57 release tags from 7.5.0 to 10.5.2 in the history clone have the same clause, setter and call (8.8.2 to 8.11.x are not tagged there; 8.11.4 was executed). Not fixed upstream as of 20ced71c1e6e (2026-10-27).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affected5 of 10 repro tests fail through gradle, assertions on and off; the finder's test 2 of 2 with the standalone runner
main ea57360912ed affectedfinder, independent reproducer and judge runs fail, assertions on and off; controls pass
10.5.2, 10.0.0, 9.12.3, 9.0.0 affectedthe repro fails 2 of 2 (on and off for 10.5.2, 9.12.3, 9.0.0)
8.11.4, 8.0.0, 7.7.3, 7.5.0 affectedthe repro fails 2 of 2 (on and off for 8.11.4 and 7.5.0, where the assertions-off throw comes from score()); independent core-only programs fail too
7.4.0 not affectedpasses, assertions on and off: no natural merge, forceMergeDeletes expunges
7.4.0 + LUCENE-7976's TieredMergePolicy.java bisectforceMergeDeletes fails; the commit scenario passes until LUCENE-8263
fix, scratch worktree at a9779ef3c193 not affectedloop fix: 115 tests incl. 10 upstream suites pass; setter fix: 42 tests pass

Independent checks

The finder's repro, an independent reproducer's own tests and controls, a devil's-advocate check and an adjudication found it real; a cross-model review (OpenAI GPT-5.6) agreed with the root cause and rated the intended-behaviour argument weak. This doc re-ran it on main a9779ef3c193 with gradle and validated two fixes.

Checks and verdicts337 words

The checks below ran on main ea57360912ed unless stated; TieredMergePolicy.java is the same file on a9779ef3c193.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateBoundary sweep flag, then a public-API JUnit: fails 2 of 2 on main (on and off), 10.5.2, 10.0.0 and 9.12.3; with the loop fix it passes and TestTieredMergePolicy still passes.
cross-model review (refute lens) not real (low), on procedure onlyOpenAI GPT-5.6, reading only: it could not run code; it agreed with the root cause, found the inputs in contract and rated the intended argument weak.
cross-model review (intended lens) real (medium)OpenAI GPT-5.6: intended-behaviour argument weak; 7 of 12 evidence items support real.
independent reproducer realOwn tests fail 3 of 4 on main, 10.5.2 and 9.12.3 (on and off); 1-byte and default controls pass; 7.5.0 and 8.11.4 fail, 7.4.0 passes; impact runs on writer state and recovery.
devil's advocate realBuilt the strongest works-as-designed case; it supports at most rejecting 0, not an exception. Own program fails on 7.5.0, 8.11.4 and main, passes on 7.4.0; 1e-6 MB works.
adjudication confirmed (high)Re-ran the reproducer's tests with a new seed: same frames; controls pass. Class corrected from 'index unusable' to 'crash'.
security screen not security-sensitiveTrusted configuration; loud, immediate failures confined to one writer; CheckIndex clean (executed probe).
registry refresh still reproducesThe finder's repro fails 2 of 2 on a9779ef3c193.
this doc's runs reproduced; two fixes validatedGradle in a scratch worktree: stock 5 of 10 fail (on and off); loop fix 115 tests pass, setter fix 42 tests pass; lint passes.
fact checks of this doc bug confirmed; wording fixedAn independent fact check re-ran the repros and the fix (bug real, two wording errors); a cross-model fact check (OpenAI GPT-5.6) found 11 errors, 7 overclaims and 6 missing caveats in the wording, all fixed or answered; a diagram check passed. An entry-point probe on a9779ef3c193 settled which calls make the writer tragic.

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,359 words

Finding record

LFV-442, snapshot of 2026-11-06

Registry title
TieredMergePolicy.setMaxMergedSegmentMB(0), a value the setter accepts, makes commit(), close() and forceMergeDeletes() throw once a segment has enough deletes: the doFindMerges candidate loop never admits a segment, so the merge candidate is empty (AssertionError with -ea, IndexOutOfBoundsException with -da); a regression since 7.5.0
Area
index / TieredMergePolicy merge selection (doFindMerges candidate loop; setMaxMergedSegmentMB validation)
Priority
Low, rank 392: SetMaxMergedSegmentMB(0) on the default TieredMergePolicy is accepted, but commit, close and forceMergeDeletes later throw loudly once deletes pile up, the same degenerate-setting profile as LFV-394, and it is less reachable than LFV-454.
Impact class
crash
Impact note
Applications that set the default TieredMergePolicy's maxMergedSegmentMB to 0 (or to any value below 2^-20 MB, or NaN, which the setter also stores as 0 bytes) get an exception from forceMergeDeletes() as soon as a segment has more than forceMergeDeletesPctAllowed (default 10%) deletes, and from commit(), close(), NRT reader opens and auto-flushing indexing calls once both the index and a segment have more than deletesPctAllowed (default 20%) deletes, on every release from 7.5.0 through 10.6.0 (10.6.0 by reading) and on main; with -da the IndexOutOfBoundsException does not make the writer tragic (the last commit stays intact and readable and swapping the merge policy recovers the writer, but a close() that throws rolls back uncommitted changes), with -ea the AssertionError is tragic; no corruption, no wrong results, no scale needed (10 documents).
Summary
You configure the default TieredMergePolicy with setMaxMergedSegmentMB(0), which the setter accepts (it rejects only negative values, with the message 'must be >=0'), delete some documents, and expect commit(), close() and forceMergeDeletes() to succeed, either with no merge or with the singleton merge the class javadoc promises for segments over the maximum ('down to 1 at once, if that one has deletions'), as they do with a 1-byte maximum and as they did on 7.4.0. Instead the candidate loop in doFindMerges requires 'bytesThisMerge < maxMergedSegmentBytes', which is 0 < 0 before the first segment is added, so no segment is ever admitted and the candidate list stays empty. 'assert candidate.size() > 0' then fails with -ea, and candidate.get(0) throws IndexOutOfBoundsException ('Index 0 out of bounds for length 0') with -da, out of the writer call.
Introduced
2519025fdafe55494448854c87e094b14f434b41 on master and its branch_7x cherry-pick 9c4e315c1cb3495f400c179159836f568cd2989d (the copy that shipped in 7.5.0), 'LUCENE-7976: Make TieredMergePolicy respect maxSegmentSizeMB and allow singleton merges of very large segments' (JIRA LUCENE-7976, now GitHub issue #9025; patches on the issue, no pull request). It moved findMerges and findForcedDeletesMerges onto one shared doFindMerges and added '&& bytesThisMerge < maxMergedSegmentBytes' to its candidate loop header [read: git log -S in the full-history clone; exec: TieredMergePolicy.java of 9c4e315c1cb shadowed onto the 7.4.0 jars makes forceMergeDeletes fail with -ea and -da, the parent's file passes]. The commit()/close() path in the repro's form fails from LUCENE-8263 (8093c450c1fd22e2bfd5b2a4a1e45c6f95f16189 on master, 8875402694b42791e18da2d3cf44a4353c676e26 on branch_7x, 2018-07-17, #9310), which lets segments with more than deletesPctAllowed deletes reach the loop [exec: per-commit shadow bisect over the nine branch_7x TieredMergePolicy commits between them]; both first shipped in 7.5.0. (2018-06-15 (LUCENE-7976); widened 2018-07-17 (LUCENE-8263))
First affected version
7.5.0 [exec] (both trigger paths); 7.4.0 is not affected [exec: passes with -ea and -da; there a 0 maximum meant no natural merges, and forceMergeDeletes ignored the maximum and still expunged deletes]
Affected versions
every release from 7.5.0: 7.5.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 (by reading), and main ea57360912ed [exec: the follow-up's history runs fail on 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main, both scenarios, with -ea, and with -da on 7.5.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main; the independent reproduction and the intended-behaviour check confirmed 7.5.0 and 8.11.4 with their own core-only main() programs] [read: all 57 release tags 7.5.0-10.5.2 in the history clone plus main have the loop clause, the 'v < 0.0'-only setter and findForcedDeletesMerges -> doFindMerges; 8.8.2-8.11.x have no tag there, 8.11.4 was executed from release jars]; unchanged on upstream main c50bc9523db3, branch_10x and branch_10_6 (the prior-report record). With -da the exception comes from score() in 7.5.0-9.0.0 and from doFindMerges' candidate.get(0) from 9.3.0 on (LUCENE-10574 moved the first use); the outcome is the same.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal
Upstream and prior reports
none found (2026-10-26; the prior-report record plus the finder's run; all read-only): four runs of the scripted prior-report search (together 66 GitHub issue and PR queries on apache/lucene plus multi-repo downstream queries, 10 commit, 9 downstream, 80 mailing-list queries over dev@, java-user@, issues@ and commits@, 16 JIRA-archive and 9 web queries), 8 GitHub-wide searches with no repository filter, 6 JIRA REST queries (LUCENE, SOLR), 9 Solr-list queries, the changed-file lists of all 411 open apache/lucene PRs (6 touch TieredMergePolicy*.java; none changes the setter or the loop clause), all 124 comments of #9025, and git log -S/--grep plus CHANGES.txt and MIGRATE.md in the full history; 26 hits, all same_bug false. Related prior items, none the same bug: #9025 / LUCENE-7976 (added the loop clause; no 0 maximum discussed); test-only commits db140ffc83a (2013, 'don't let maxMergedSegmentMB be 0.0') and fd44b39c03b (2015, 'do not use near-zero maxMergedSegmentMB for this test!'), which keep randomized tests away from a 0 or near-0 maximum because before 7.5.0 it disabled natural merging and made those tests fail falsely (no exception, no production change); #5390 / LUCENE-4323 (added the '>=0' check); #8062 / LUCENE-7005 (tightened only setFloorSegmentMB's message). Code unchanged on upstream main c50bc9523db3, branch_10x and branch_10_6. Gaps: GitHub's search rate limit, punctuation-insensitive exact-string search, list bodies cached to 400 characters.
Lucene commit tested
main a9779ef3c193 (2026-10-26) through gradle in a clean scratch worktree, assertions on and off, and the fix in the same worktree (repro, 10 upstream merge and delete suites, format and lint checks); the standalone JUnit runner on the built jars. Earlier runs: main ea57360912ed (the same TieredMergePolicy.java blob), the release jars 7.4.0, 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2, and a per-commit shadow bisect onto 7.4.0. Upstream main 20ced71c1e6e (2026-10-27) has the same file (read).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:42-44 (at ea57360912ed) (class javadoc: an over-size merge shrinks 'down to 1 at once, if that one has deletions'), :61 ('findForcedDeletesMerges should never produce segments greater than maxSegmentSize'), :67 (@lucene.experimental), :81-82 (forceMergeDeletesPctAllowed = 10.0, deletesPctAllowed = 20.0), :98-109 (setMaxMergedSegmentMB javadoc gives no range; 'if (v < 0.0)' and 'maxMergedSegmentMB must be >=0'; (long) cast at :107 maps every v below 2^-20 MB, and NaN, to 0 bytes), :168-170 (setFloorSegmentMB rejects 'v <= 0.0' with 'must be > 0.0'), :381-397 (findMerges sets aside a segment over maxMergedSegmentBytes / 2 unless both its own and the index's deleted percentage exceed deletesPctAllowed), :438 (findMerges -> doFindMerges), :539-548 (candidate loop header, 'bytesThisMerge < maxMergedSegmentBytes' at :546), :552-570 (singleton-merge branch, comment at :559-560), :576-580 ('assert candidate.size() > 0' at :578, candidate.get(0) at :580), :925-977 (findForcedDeletesMerges keeps segments above forceMergeDeletesPctAllowed and calls doFindMerges at :968)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2445-2496 (at ea57360912ed) (maybeMerge -> updatePendingMerges -> findFullFlushMerges on commit, close and flush)
The finding in 30 seconds
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:42-44, 81-82, 102-109, 347-352, 381-397, 438, 520-525, 533-580, 745-921, 925-977
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:635, 704-721, 1325-1337, 1344-1352, 1631-1641, 2248, 2348, 2359, 2425, 2438-2448, 2493-2509, 3737-3743, 3858-3865, 3879-3884, 3960, 4261-4266, 4356-4358, 4379, 6345-6360; IndexWriterConfig.java:108, 483-500; MergePolicy.java:689-693
Why it looks unintended
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:42-44, 61, 67, 97-109, 168-170, 559-560, 576-578
  • a Lucene main checkout lucene/core/src/test/org/apache/lucene/index/TestTieredMergePolicy.java:759-793; lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:1975-1977
  • a Lucene main checkout lucene/CHANGES.txt:7224-7231
Suggested fix
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:404-413, 438-447, 537, 552-573, 626-627

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 (the repro's two scenarios with a 0-byte and a 1-byte maximum); the PNG is the 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.