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.
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.- Configure the default merge policy with
new TieredMergePolicy().setMaxMergedSegmentMB(0). The setter accepts it: it rejects only negative values ("must be >=0"). - 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. - Instead
commit()throwsIndexOutOfBoundsException: Index 0 out of bounds for length 0(anAssertionErrorwith assertions on).forceMergeDeletes()on two 5-document segments with 1 delete each throws the same way; so doclose(), NRT reopens and indexing calls that flush, while those deletes remain. - 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. - It needs deletes: with the defaults, more than 20% of the index and of a segment for
commit(), more than 10% of a segment forforceMergeDeletes(). With the default settings the index on disk stays at the last commit; 7.4.0 accepted 0 without error.
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).
- Merge candidate: the list of segments
TieredMergePolicyconsiders merging together.doFindMergesbuilds 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).
setMaxMergedSegmentMB(:102-109) throws only forv < 0.0("maxMergedSegmentMB must be >=0") and stores(long) (v * 1024 * 1024): 0, -0.0, every positive value below 2^-20 MB and NaN becomemaxMergedSegmentBytes = 0; negative values are rejected.- With a 0 maximum,
findMergessets aside every non-empty segment as too large (> maxMergedSegmentBytes / 2) unless both the index and the segment are more thandeletesPctAllowed(default 20%) deleted (:381-397), then callsdoFindMerges(:438).findForcedDeletesMergespasses every segment with more thanforceMergeDeletesPctAllowed(default 10%) deletes (:957-963, call at:968). Both leave out segments that are already merging. - The candidate loop's header includes
bytesThisMerge < maxMergedSegmentBytes(:539-548, clause at:546).bytesThisMergestarts at 0, so the header is0 < 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. assert candidate.size() > 0(:578) fails with assertions on;segInfosSizes.get(candidate.get(0))(:580) throwsIndexOutOfBoundsExceptionwith them off.- Callers:
commit()andprepareCommit()throughfindFullFlushMerges(IndexWriter.java:3863->:2496,MergePolicy.java:693; with the default 500 msmaxFullFlushMergeWaitMillis,:3858); NRT reopens (:635,:704-706),close(), an explicitflush(),maybeMerge()and segment-flushing adds throughmaybeMerge(:2445->:2508,:6345-6360);forceMergeDeletes()(:2359), or already in its preliminaryflush(true, true)(:2348) when deletes are still unflushed. The selection offorceMerge(1)(findForcedMerges,TieredMergePolicy.java:745-921) does not usedoFindMergesand is not affected (executed).
A 1-byte maximum makes the header 0 < 1, so the first segment enters and becomes a singleton merge.
| case | expected | main |
|---|---|---|
0 maximum: 10 docs, delete 5, add 1, commit() | 6 live docs | AssertionError at :578 / IndexOutOfBoundsException at :580 |
0 maximum: two 5-doc segments, 1 delete each, forceMergeDeletes() | 8 live docs | the same exception |
0 maximum: 100 docs, delete 50, commit() or close() | succeeds | the same exception; close() rolls back |
1-byte or default maximum, the 100-doc commit() and forceMergeDeletes() cases | succeeds | succeeds; 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() | succeeds | throws after the commit was written (numDocs 50 on disk) |
| 0 maximum, append only or 6% deletes | succeeds | succeeds |
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
TieredMergePolicywith 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 whenforceMergeDeletes()runs on a segment with more thanforceMergeDeletesPctAllowed(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, asclose()documents for any exception. WithsetMaxFullFlushMergeWaitMillis(0)acommit()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.
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.
/*
* 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);
}
}
}
}
# 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:AssertionErroratTieredMergePolicy.java:578(-ea) orIndexOutOfBoundsException: Index 0 out of bounds for length 0at:580(-da), fromfindForcedDeletesMerges.
More files, environment, full output41 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 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)- The setter accepts 0 by its own check (
must be >=0); the siblingsetFloorSegmentMBrejects 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.setFloorSegmentMBchecksv <= 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" withassert candidate.size() > 0(:576-578). - Tests.
TestTieredMergePolicy.testSetters(:759-793) tries 0.5, +Infinity,Long.MAX_VALUE / 1 MiBand -2.0 (rejected), not 0. The test framework randomizessetMaxMergedSegmentMB(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,
findMergesset every segment aside (no natural merges) andfindForcedDeletesMergesignored the maximum ("Don't enforce max merged size here"). LUCENE-7976 (7.5.0) put both ondoFindMergesand 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.
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.
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), becausefindMergespassesmaxMergeIsRunning = mergingBytes >= maxMergedSegmentBytes(:447), which is0 >= 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
segsPerTierx max(smallest segment,floorSegmentBytes) bytes (with the defaults 128 MB or more), the tier loop (:402-413) reaches a 0 level size; thedoubledivision gives +Infinity (NaN at exactly that size), the loop stops atlevelSize == maxMergedSegmentBytes(:406), and(int) allowedSegCount(:442) becomesInteger.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, seedDEADBEEF. Stock: 5 of 10 repro tests fail, assertions on and off. With the fix: the 3 repro classes (10 tests) andTestTieredMergePolicy,TestIndexWriterMergePolicy,TestIndexWriterForceMerge,TestIndexWriterMerging,TestIndexWriterDelete,TestForceMergeForever,TestSoftDeletesRetentionMergePolicy,TestUpgradeIndexMergePolicy,TestOneMergeWrappingMergePolicyandTestThreadedForceMerge: 115 tests, 12 skipped, 0 failures. Assertions off: the repro classes,TestTieredMergePolicyandTestIndexWriterMergePolicy, 48 tests, 0 failures. - A fix-outcome check (commit and forceMergeDeletes with a 0 maximum, maxDoc and numDocs asserted) passes;
checkGoogleJavaFormat,ecjLintMainandforbiddenApisMainonlucene/corepass.
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.
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.
Map 0 to Long.MAX_VALUE in the setter.
Trade-offs
Silently changes what the value means; Lucene never documented that meaning.
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".
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:
setMaxMergedSegmentMBaccepts 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
IndexOutOfBoundsExceptionfor everymaxMergedSegmentMBvalue that maps to 0 bytes; 1.0 and larger were clean. Its workload deletes while indexing and callscommit()beforeforceMergeDeletes(), and the first exception ends a run, so with 0 the sweep did not get toforceMergeDeletes()on its own (from reading the workload); the finder's public-API JUnit then showed that path as well.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA archive; downstream repositories; web | 2026-10-26 | the 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, expungeDeletes | no report; nearest #9025 / LUCENE-7976 and the test-only commits db140ffc83a, fd44b39c03b |
| GitHub-wide, JIRA (LUCENE, SOLR), Solr lists, open PRs, history | 2026-10-26 | 8 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.md | no 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 list | 2026-10-28 | setMaxMergedSegmentMB; maxMergedSegmentMB 0; TieredMergePolicy IndexOutOfBoundsException; doFindMerges; open PRs TieredMergePolicy | nothing new; the file last changed in 6643a5dbe1ed (2026-01-23), before a9779ef3c193 |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | 5 of 10 repro tests fail through gradle, assertions on and off; the finder's test 2 of 2 with the standalone runner |
main ea57360912ed | affected | finder, independent reproducer and judge runs fail, assertions on and off; controls pass |
| 10.5.2, 10.0.0, 9.12.3, 9.0.0 | affected | the 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 | affected | the 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 affected | passes, assertions on and off: no natural merge, forceMergeDeletes expunges |
| 7.4.0 + LUCENE-7976's TieredMergePolicy.java | bisect | forceMergeDeletes fails; the commit scenario passes until LUCENE-8263 |
fix, scratch worktree at a9779ef3c193 | not affected | loop fix: 115 tests incl. 10 upstream suites pass; setter fix: 42 tests pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Boundary 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 only | OpenAI 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 | real | Own 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 | real | Built 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-sensitive | Trusted configuration; loud, immediate failures confined to one writer; CheckIndex clean (executed probe). |
| registry refresh | still reproduces | The finder's repro fails 2 of 2 on a9779ef3c193. |
| this doc's runs | reproduced; two fixes validated | Gradle 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 fixed | An 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. |
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 476 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,359 words
- 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: mainea57360912ed(the sameTieredMergePolicy.javablob), 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 main20ced71c1e6e(2026-10-27) has the same file (read).
- 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-977a 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-578a 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-1977a 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
- TestTieredMergePolicyZeroMaxMergedSegment.java (JUnit test):
repro/LFV-442/src/org/apache/lucene/index/TestTieredMergePolicyZeroMaxMergedSegment.java(47 lines)
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.
assets/LFV-442/bug.png(476 KB)
