A NaN-blind check lets NaN in, and the loop that counts allowed segments then has no reachable exit and runs while holding the writer's lock.
- After each flush, commit or NRT reopen,
IndexWriterasks its merge policy which segments to merge (findMerges). That call runs while the writer's lock is held. TieredMergePolicy, the default policy, first counts how many segments the index may keep. It walks size tiers that start at the larger of the smallest segment and the floor size (16 MB here) and grow by a factor of(int) segmentsPerTier, and stops at the first tier where the remaining bytes fill fewer thansegmentsPerTiersegments of that tier's size, or at the maximum merged segment size (5 GB).segmentsPerTier(default 8.0, minimum 2.0) is set withsetSegmentsPerTier(double).
setSegmentsPerTier(NaN) passes the v < 2.0 check; in findMerges the tier size falls to 0, both loop exits stay false, and the flushing thread spins while holding the IndexWriter lock. Select the still to open it at full size. Generated with AI, checked against the code.- Set
segmentsPerTierto NaN on the default merge policy, for example from a configuration string read withDouble.parseDouble("NaN"). You expectIllegalArgumentException: segmentsPerTier must be >= 2.0, as for 1.9. Instead the setter stores NaN: its only check isv < 2.0, and every ordered comparison (<,<=,>,>=) with NaN is false. - Add one document and call
flush(). You expect it to return at once: with 2.0 or +Infinity, flush, commit and NRT open take 43, 6 and 35 ms. - Instead
flush()never returns.findMergesuses(int) NaN= 0 as its tier growth factor, so the tier size in itswhile (true)loop drops from 16 MB to 0 and the per-tier segment count becomes 0 / 0.0 = NaN. Both exit tests,NaN < NaNand0 == 5 GB, stay false; one core spins at 100%. - The loop runs inside the synchronized
IndexWriter.updatePendingMerges, so commit, close, rollback and other indexing threads block behind it.interrupt()and setting 8.0 back do not stop it; only a JVM restart does.
Anyone who sets segmentsPerTier from a value that can be NaN, such as a parsed configuration string or a computed ratio; the default 8.0 and constants are safe. One document is enough. The writer then stays stuck: no exception, one busy core, other writer calls blocked until a JVM restart. No committed data is lost. Rare: unreported in 15 years.
Mechanism, terms and numbers684 words
TieredMergePolicy.setSegmentsPerTier rejects values below 2.0 with "segmentsPerTier must be >= 2.0", but its only check is if (v < 2.0), which is false for NaN, so NaN is stored. The next findMerges call then never returns: mergeFactor = (int) segsPerTier is 0, the level size of the allowed-segment-count loop drops to 0, and neither exit test of its while (true) can become true (with any maxMergedSegmentMB above 0, such as the default 5 GB). findMerges runs inside the synchronized IndexWriter.updatePendingMerges, so the spinning thread holds the writer's monitor: commit, close, rollback, getDocStats, hasPendingMerges and other indexing threads block behind it, and neither Thread.interrupt() nor setting a valid value on the live policy ends it. One document and one flush(), commit() or DirectoryReader.open(writer) are enough, with the default IndexWriterConfig, assertions on or off. Every release since TieredMergePolicy appeared (3.2.0, 2011) through 10.6.0, and main, hangs. The class's four other double setters accept NaN through the same kind of check; with forceMergeDeletesPctAllowed = NaN, forceMergeDeletes() merges nothing.
TieredMergePolicy.java:219-225 on main 645d4fe14a20:
public TieredMergePolicy setSegmentsPerTier(double v) {
if (v < 2.0) {
throw new IllegalArgumentException("segmentsPerTier must be >= 2.0 (got " + v + ")");
}
segsPerTier = v;
return this;
}NaN < 2.0 is false, so NaN is stored.
TieredMergePolicy.java:400-413 (annotated: the end-of-line comments are added and the comment line :401 is left out):
final int mergeFactor = (int) segsPerTier; // (int) NaN == 0
long levelSize = Math.max(minSegmentBytes, floorSegmentBytes); // 16 MB by default
long bytesLeft = totIndexBytes;
while (true) {
final double segCountLevel = bytesLeft / (double) levelSize;
if (segCountLevel < segsPerTier || levelSize == maxMergedSegmentBytes) {
allowedSegCount += Math.ceil(segCountLevel);
break;
}
allowedSegCount += segsPerTier;
bytesLeft -= segsPerTier * levelSize; // (long) NaN == 0
levelSize = Math.min(maxMergedSegmentBytes, levelSize * mergeFactor); // 16 MB * 0 == 0
}- One flushed document makes a segment of about 1 KB (994 bytes in one probe run). Pass 1:
levelSize= 16 MB,segCountLevel= 994 / 16,777,216, about 0.00006. With the default 8.0 the loop breaks here, because 0.00006 < 8. - With NaN,
0.00006 < NaNis false and 16 MB is not the maximum merged segment size (5 GB), so there is no break.bytesLeftbecomes(long) NaN= 0 andlevelSizebecomesmin(5 GB, 16 MB * 0)= 0. - Pass 2 and every later pass:
segCountLevel= 0 / 0.0 = NaN,NaN < NaNis false and 0 is not 5 GB. The state never changes, so the loop never ends.
flush() calls maybeMerge(FULL_FLUSH) (IndexWriter.java:4378-4379), which calls the private synchronized updatePendingMerges (:2442-2446, :2454), which calls findMerges (:2508). commit() and DirectoryReader.open(writer) reach it through findFullFlushMerges (:2496), whose default calls findMerges (MergePolicy.java:693). The findMerges javadoc says the call "is always synchronized on the IndexWriter instance" (MergePolicy.java:602-605). By reading, an automatic flush during addDocument takes the same path (IndexWriter.java:6353-6359); not executed.
The loop has no blocking call, so Thread.interrupt() has no effect. Its state lives in locals (mergeFactor is a final local; levelSize and bytesLeft are already 0), so calling setSegmentsPerTier(8.0) on the live policy does not help either. Both were executed on main, 10.6.0, 10.5.2 and 9.12.3.
+Infinity is accepted too but harmless: segCountLevel < Infinity breaks on pass 1. The second exit, levelSize == maxMergedSegmentBytes, would fire only with maxMergedSegmentMB = 0.
setMaxMergedSegmentMB (v < 0.0, :103), setDeletesPctAllowed (v <= 0 || v > 50, :139), setFloorSegmentMB (v <= 0.0, :169) and setForceMergeDeletesPctAllowed (v < 0.0 || v > 100.0, :196) accept NaN the same way. With forceMergeDeletesPctAllowed = NaN, pctDeletes > forceMergeDeletesPctAllowed (:947) is never true, so forceMergeDeletes() merges nothing (the study left 6 deleted documents in place, against 0 with the default 10%). maxMergedSegmentMB = NaN is stored as 0 bytes and behaves like the accepted value 0, which is a separate bug.
Exposure and likelihood226 words
- Who. Applications that set
segmentsPerTieron the defaultTieredMergePolicyfrom a value that can be NaN: a configuration string parsed withDouble.parseDouble(which accepts "NaN") or a computed ratio such as 0.0 / 0.0. - Effect. The first flush, commit or NRT reopen after the setting takes effect never returns and burns one core. The spinning thread holds the
IndexWritermonitor, so commit, close, rollback,getDocStatsandhasPendingMergesfrom other threads stay BLOCKED with it as lock owner, and a concurrent indexing thread stops when it publishes its next flushed segment (in one probe, with a 1 MB RAM buffer, it added 1,580 documents in the first second, then none for the remaining 7 s of the probe, BLOCKED inpublishFlushedSegmentwith the spinner as lock owner). There is no exception and no log line; only a JVM restart recovers. - What it does not do. Nothing committed is lost or corrupted: the last commit stays intact and the pending commit simply never happens, so documents added since the last commit are gone after the restart.
- Scale. Tiny: one document, the default
IndexWriterConfig, any directory, assertions on or off. - Likelihood: rare. Normal code passes constants, Lucene's own randomized tests draw values from 2 to 50, and nobody has reported it since 3.2.0 (2011). Whoever does feed a NaN gets a certain, unrecoverable hang instead of an error.
One JUnit class, one method, public API only: set segmentsPerTier to NaN, add one empty document to a writer on a ByteBuffersDirectory, and call flush() under a 10 s @Timeout. If the setter rejects NaN the test returns, so it passes with either kind of fix. It fails with assertions on and off.
/*
* 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 com.carrotsearch.randomizedtesting.annotations.Timeout;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestTieredMergePolicySegmentsPerTierNaN extends LuceneTestCase {
@Timeout(millis = 10_000)
public void testFlushWithSegmentsPerTierNaN() throws Exception {
TieredMergePolicy tmp = new TieredMergePolicy();
try {
tmp.setSegmentsPerTier(Double.NaN);
} catch (IllegalArgumentException rejected) {
return;
}
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setMergePolicy(tmp))) {
w.addDocument(new Document());
// expected: NaN rejected or flush() returns; actual: flush() spins forever in findMerges
w.flush();
}
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestTieredMergePolicySegmentsPerTierNaN -Ptests.seed=DEADBEEF
- Expected
setSegmentsPerTier(NaN)throwsIllegalArgumentException, orflush()of one document returns within 10 s.- Observed
Test timeout exceeded (>= 10000 msec), and the test thread is still RUNNABLE inTieredMergePolicy.findMerges(TieredMergePolicy.java:413)underIndexWriter.flush.
More files, environment, full output461 words
The class sits in org.apache.lucene.index next to TestTieredMergePolicy, but uses nothing package-private. Default IndexWriterConfig, one empty document, no threads, and the test draws no random values (LuceneTestCase still picks its usual randomized defaults, such as the codec). The bound is randomizedtesting's @Timeout(millis = 10_000), the idiom upstream uses to bound a test that could hang.
Because the spinning thread cannot be interrupted, a failing run does not end at the timeout: the runner also reports a suite-level thread leak (the test thread still RUNNABLE in findMerges), a zombie-thread error and a sysout-limit error from the leak dumps, and lingers for the thread, so a failing run takes about 35 s. All of these come from the one loop.
Regression test: this same test, with one change for Lucene's build: write the catch as catch (IllegalArgumentException _), because the core module's ECJ lint rejects an unused exception parameter. That form fails on stock main with the same timeout and passes with the fix and the format, ECJ and forbidden-apis checks.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; releases 3.2.0 to 10.6.0 (12 run): hang; with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the loop is plain double and long arithmetic) |
cp $REPRO/TestTieredMergePolicySegmentsPerTierNaN.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestTieredMergePolicySegmentsPerTierNaN -Ptests.seed=DEADBEEF
# about 35 s for a failing run; assertions off fails the same way: -Ptests.asserts=false
# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on (stacks trimmed)
TestTieredMergePolicySegmentsPerTierNaN > testFlushWithSegmentsPerTierNaN FAILED
java.lang.Exception: Test timeout exceeded (>= 10000 msec).
at __randomizedtesting.SeedInfo.seed([DEADBEEF:4A4471D852F73166]:0)
TestTieredMergePolicySegmentsPerTierNaN > executionError FAILED
org.gradle.internal.exceptions.DefaultMultiCauseException: Multiple Failures (3 failures)
java.lang.AssertionError: The test or suite printed 20980 bytes to stdout and stderr, even though the limit was set to 8192 bytes. ...
com.carrotsearch.randomizedtesting.ThreadLeakError: 1 thread leaked from SUITE scope at org.apache.lucene.index.TestTieredMergePolicySegmentsPerTierNaN:
1) Thread[id=21, name=TEST-TestTieredMergePolicySegmentsPerTierNaN.testFlushWithSegmentsPerTierNaN-seed#[DEADBEEF]-worker, state=RUNNABLE, ...]
at org.apache.lucene.index.TieredMergePolicy.findMerges(TieredMergePolicy.java:413)
at org.apache.lucene.index.IndexWriter.updatePendingMerges(IndexWriter.java:2508)
at org.apache.lucene.index.IndexWriter.maybeMerge(IndexWriter.java:2445)
at org.apache.lucene.index.IndexWriter.flush(IndexWriter.java:4379)
at org.apache.lucene.index.IndexWriter.flush(IndexWriter.java:4357)
at org.apache.lucene.index.TestTieredMergePolicySegmentsPerTierNaN.testFlushWithSegmentsPerTierNaN(TestTieredMergePolicySegmentsPerTierNaN.java:39)
com.carrotsearch.randomizedtesting.ThreadLeakError: There are still zombie threads that couldn't be terminated: ...
2 tests completed, 2 failed
# plain main() probe, main 645d4fe14a20, assertions off (not the repro)
setSegmentsPerTier(NaN) accepted; getSegmentsPerTier() = NaN
flush: did NOT return within 5000 ms; state=RUNNABLE; thread CPU 999 ms in a 1000 ms window
second thread calling hasPendingMerges(): state=BLOCKED, ... lock owner=probe-flush (the flush thread)
after interrupt(): flush thread alive=true, state=RUNNABLE
after setSegmentsPerTier(8.0) on the live policy: flush thread alive=true, state=RUNNABLE
commit: did NOT return within 5000 ms; state=RUNNABLE; thread CPU 1000 ms in a 1000 ms window
nrt-open: did NOT return within 5000 ms; state=RUNNABLE; thread CPU 1000 ms in a 1000 ms window
RESULT: 3 of 3 writer calls did not return (segmentsPerTier=NaN)
# the same probe with 2.0: flush, commit and NRT open returned in 43, 6 and 35 ms- The setter checks its argument and names the range in its message, "segmentsPerTier must be >= 2.0"; NaN slips through only because ordered comparisons with NaN are false.
- No javadoc, test or commit says NaN is allowed;
testSetterstries +Infinity and negatives, never NaN. - Lucene rejects NaN elsewhere on purpose (BM25, LUCENE-6982;
LRUQueryCache, #14412), and SECURITY.md welcomes endless-loop fixes.
The full argument214 words
- The setter enforces a domain. The message "segmentsPerTier must be >= 2.0 (got ...)" (
TieredMergePolicy.java:221) states the legal range; NaN is outside it. With the one-line fix, the same call throws "segmentsPerTier must be >= 2.0 (got NaN)". - Nothing makes NaN intended. The javadoc (
:213-218) gives only the meaning and the default 8.0.TestTieredMergePolicy.testSetters(:759-793) accepts+Infinityand rejects-2.0but never tries NaN;git log -G isNaNover the merge-policy sources finds no commit; no issue discusses it. - Lucene's practice. LUCENE-6982 (#8039): "Rather than strange behavior, we should throw an exception if these values are wrong"; its commit added NaN checks for BM25's
k1andb(BM25Similarity.java:53-60today). On #14412 jpountz asked to keepLRUQueryCache"rejecting skipCacheFactor=Float.NaN" (review comment r2019386671, 2025-03-28); the check readsif (skipCacheFactor >= 1 == false) { // NaN >= 1 evaluates false(LRUQueryCache.java:178). - A hang, not a trade-off. The result is not a different merge choice but a call that never returns while holding the writer lock. Lucene's SECURITY.md (line 46) says issues outside its security scope "are still valid bug reports and hardening suggestions" and names "a change that prevents an endless loop" as a welcome example (framed there around broken indexes); this fix touches no hot path.
The devil's-advocate check146 words
The strongest case for "not a bug" (made by the cross-model refute review, OpenAI GPT-5.6, read-only, and by the devil's-advocate verifier): garbage in. NaN is not a segment count; the javadoc gives no range and no @throws; the class is @lucene.experimental; and core has other ordered-only checks that admit NaN (LiveIndexWriterConfig.setRAMBufferSizeMB, CompoundFormat.setMaxCFSSegmentSizeMB) or no check at all (the LogByteSizeMergePolicy MB setters).
Why it fails: the setter already validates its argument and names the range, so a value outside that range that gets through is the defect; the reviewer itself agreed with the root cause and called the guard a reasonable hardening change. @lucene.experimental is about API stability, not permission to hang. The other NaN-blind setters show inconsistency, not intent, and none of them was shown to hang anything. Verdict: a defect, most likely treated upstream as a small validation fix.
Recommended Reject NaN in setSegmentsPerTier and the four sibling setters (validated)
Reject NaN in the setter: if (Double.isNaN(v) || v < 2.0), plus the same Double.isNaN(v) || in the class's four other double setters (5 lines). With it the repro passes, and so do TestTieredMergePolicy (assertions on and off) and the core module's format, ECJ and forbidden-apis checks. The setSegmentsPerTier line alone removes the hang.
public TieredMergePolicy setSegmentsPerTier(double v) {
if (Double.isNaN(v) || v < 2.0) {
throw new IllegalArgumentException("segmentsPerTier must be >= 2.0 (got " + v + ")");
}
segsPerTier = v;
return this;
}
// the same `Double.isNaN(v) ||` prefix in setMaxMergedSegmentMB, setDeletesPctAllowed,
// setFloorSegmentMB and setForceMergeDeletesPctAllowedAll options and trade-offs4 options · 494 words
One fix was built and validated through gradle in a scratch worktree of 645d4fe14a20; the one-line variant was validated earlier with a standalone runner. The other options are sketches, not built.
Add Double.isNaN(v) || to the five double setter checks of TieredMergePolicy (+5/-5 lines). The setSegmentsPerTier line is the one that removes the hang; the other four close the same gap in setMaxMergedSegmentMB, setDeletesPctAllowed, setFloorSegmentMB and setForceMergeDeletesPctAllowed.
public TieredMergePolicy setSegmentsPerTier(double v) {
if (Double.isNaN(v) || v < 2.0) {
throw new IllegalArgumentException("segmentsPerTier must be >= 2.0 (got " + v + ")");
}
segsPerTier = v;
return this;
}
// the same `Double.isNaN(v) ||` prefix in setMaxMergedSegmentMB, setDeletesPctAllowed,
// setFloorSegmentMB and setForceMergeDeletesPctAllowedTrade-offs
- Changes behaviour only for NaN, which is outside every one of these setters' stated ranges. Where its effect was run it never worked:
segmentsPerTierhung the writer andforceMergeDeletesPctAllowedmadeforceMergeDeletes()a no-op; by reading, the two MB setters store it as 0 bytes (a valuesetFloorSegmentMBitself rejects). The effect ofdeletesPctAllowed = NaNwas not measured. Code that passes NaN now fails at configuration time with the setter's existing message ("... (got NaN)"). +Infinitystays accepted;testSettersrelies on that for the MB setters, and it is harmless forsegmentsPerTier.- No change on any hot path: the check runs once per setter call.
What the checks validated
- Scratch worktree of
645d4fe14a20, gradle, OpenJDK 25.0.3, seed DEADBEEF. Stock: the repro times out (10.07 s test, 35.1 s suite). - With the fix: the repro passes (the setter now throws, so the test returns early), and
TestTieredMergePolicyruns 17 tests, 1 skipped (@Nightly), 0 failures, with assertions on and off. checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestof:lucene:corepass with the regression-test form of the repro (catch (IllegalArgumentException _)); that form fails on stock main with the same timeout.
if (Double.isNaN(v) || v < 2.0) in setSegmentsPerTier alone.
Trade-offs
The smallest change that removes the hang; it leaves the four sibling setters accepting NaN (and forceMergeDeletes() a no-op with forceMergeDeletesPctAllowed = NaN).
What the checks validated
With the standalone runner on 645d4fe14a20: the minimal test passes, and a plain main() probe prints "setSegmentsPerTier(NaN) rejected: segmentsPerTier must be >= 2.0 (got NaN)".
Invert each condition so NaN fails it, as LRUQueryCache does: if (v >= 2.0 == false).
Trade-offs
Same effect as the recommended option; a matter of style. Not built separately.
Defensive: break out of the allowed-segment-count loop when levelSize stops growing (for example when it is 0 or mergeFactor < 2).
Trade-offs
Protects against future bad states, but on its own it would hide a broken configuration behind odd merge decisions instead of an error. Not built.
Downstream, a server that hands a user-editable segments-per-tier setting to this setter after parsing it with Double.parseDouble and only a minimum check would let anyone allowed to change that setting hang that index's writers, so such servers should reject NaN themselves until Lucene does.
A static scan of Lucene's public methods listed every argument check that throws IllegalArgumentException, and an exploration of each check with the double argument set to NaN predicted that all five TieredMergePolicy double setters accept it. A merge-policy sweep then saw a flush that never returned with segmentsPerTier = NaN.
The method in full168 words
Method: semi-formal (a bytecode inventory and an abstract path exploration, then dynamic probes).
- Inventory. A static pass with the JDK 25 class-file API over 27 core and non-sandbox module jars listed every public or protected constructor, factory, setter and method of a public class whose bytecode reaches an
IllegalArgumentExceptionguard (1,231 of them), with the compared parameters and constants. - Prediction. For every float or double parameter, an abstract exploration of the guard's paths with the parameter fixed to NaN, +Infinity and -Infinity predicted accept or reject; for the five
TieredMergePolicydouble setters it predicted ACCEPT for NaN. - Confirmation. A dynamic probe on the real jar confirmed the acceptance; a merge-policy sweep (272 configurations) saw no completion within 15 s for
segmentsPerTier = NaN, with the worker infindMerges:413; a public-API JUnit then witnessed the hang. An independent reproducer, a devil's-advocate verifier, a cross-model review (OpenAI GPT-5.6), an adjudication, a follow-up (history, prior reports, security), a credibility audit, a re-verification on current main and a tie-break followed.
No prior report found (2026-10-31: three scripted prior-report search runs of 63 queries each over GitHub, the JIRA archive, the mailing lists, downstream repositories and the web, plus 37 manual GitHub searches and the files of all 409 open PRs; the same verdict on 2026-10-26). Closest: #14412, a maintainer asking to keep NaN rejection in another class.
Searches2 searches
Verdict new: no issue, PR, review comment, commit, JIRA entry, list thread or downstream issue says that TieredMergePolicy's double setters accept NaN, that segmentsPerTier = NaN makes findMerges loop forever, or that a flush or commit hangs this way. The closest item, the #14412 review comment r2019386671 (keep LRUQueryCache rejecting NaN), is about another class. Context only, none mentions NaN: #2153 (LUCENE-1076, origin of the check), #9439 (LUCENE-8392) and #14165 (mergeFactor), #8062, #9310 and #11761 (setter range edits), #9025 (a different endless retry loop), #9050 (a findMerges stack caused by a missing file). No open PR changes a setter check, mergeFactor or NaN handling.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs, review comments and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr, Lucene.NET; all of GitHub), JIRA, mailing lists, web | 2026-10-31 | three scripted prior-report search runs (identifiers, symptoms, the other setters; 63 queries each), 37 GitHub GraphQL searches, content scans of every apache/lucene issue, PR and comment updated since 2026-10-24, six JQL queries, list queries over dev@, java-user@, issues@ and commits@, and the changed files of all 409 open PRs | no report; closest #14412 r2019386671 (another class) |
| Study search (follow-up, finder, verifiers) | 2026-10-26 | three scripted prior-report search runs of 65 queries each, 65 manual GitHub searches, 24 list queries, 8 JQL queries, a content scan of 3,207 issues and PRs, the files of all 410 open PRs | no report |
Introduced with the class itself (LUCENE-1076, f10d92398bc, 2011-04-02) and first released in 3.2.0. Thirteen versions from 3.2.0 to 10.6.0 and main were run and all hang; 3.1.0 has no TieredMergePolicy. Never fixed, not a regression.
Versions tested5 rows
Introduced by f10d92398bc688b40498d9a4c765728ed446e050 "LUCENE-1076: new TieredMergePolicy" (Michael McCandless, trunk, 2011-04-02), shipped through the branch_3x backport ec542dd385e588075207cde69b9eefc072bcea3f (2011-05-05). No pull request (Subversion era); issue LUCENE-1076 = #2153, whose 40 comments never mention NaN or segmentsPerTier. On main, plain git blame still attributes the setter with its v < 2.0 check to it, and git blame -w -M -C (ignoring whitespace and moved or copied code) also the loop skeleton; plain blame gives those loop lines to later refactorings (LUCENE-8385, LUCENE-8393, LUCENE-7976, #13430, #14165). No commit ever added a NaN check to the file.
How the loop changed, the hang did not: in 3.2.0-7.4.0 the only exit was segCountLevel < segsPerTier; LUCENE-8392 and LUCENE-8393 (7.5.0) added the exit levelSize == maxMergedSegmentBytes and mergeFactor = (int) Math.min(maxMergeAtOnce, segsPerTier) (0 for NaN; still so in 10.6.0 and branch_10_6); #14165 (unreleased; on main and branch_11x) made it (int) segsPerTier, still 0.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20; 10.6.0; 10.5.2 | affected | minimal repro times out with assertions on and off; thread RUNNABLE in findMerges (:413, :454, :452) |
| 9.12.3, 8.11.4, 7.5.0, 7.4.0, 6.6.6, 5.5.5, 4.10.4, 4.0.0, 3.6.2 | affected | commit of one document does not return in 20 s; a second writer caller BLOCKED behind it |
| 3.2.0 | first release | the same hang (findMerges:300), with an independently written 3.x repro |
| 3.1.0 | not affected | no TieredMergePolicy class |
upstream main 2f424fd428da, branch_10_6 | read | the same v < 2.0 check and loop |
The finder, an independent reproducer, a devil's-advocate verifier, an adjudication, a credibility audit, a re-verification on current main and a tie-break each reproduced the hang with their own test or probe. The cross-model review (OpenAI GPT-5.6) ran no Java and argued only that NaN is outside the contract; it agreed with the root cause.
Checks and verdicts294 words
Every executed check ran on stock builds. The cross-model reviews were read-only, so no executed cross-model check exists.
| Check | Verdict | What it found |
|---|---|---|
| Finder (scan, sweep, 4-test JUnit) | candidate | 3 of 4 fail on main with assertions on and off, on 10.5.2, 10.0.0 and 9.12.3; the five-line fix passes 4 of 4 and upstream TestTieredMergePolicy with 16 run and 0 failures (1 skipped). |
| Independent reproducer (own 9-test JUnit) | real | 7 of 9 fail on main (on and off), 10.5.2 and 9.12.3; both controls pass; a hasPendingMerges caller BLOCKED with the flusher as lock owner. |
| Cross-model review, refute lens (OpenAI GPT-5.6, read-only) | not real (contract only) | Ran no Java. Agreed with the root cause and the mechanism; argued NaN is outside the parameter's domain. Weighed and rejected by the adjudication and the tie-break (see "Why it is a bug"). |
| Devil's advocate | real | Own main() probe still RUNNABLE after 5 s via findFullFlushMerges; 2.0 and +Infinity return. |
| Adjudication | confirmed | Re-ran the independent reproduction's JUnit on main with assertions on and off: 7 of 9 fail, controls pass. |
| Follow-up and credibility audit | history, prior art, security; keep | 13 versions run; prior-art verdict new; the security probe showed interrupt and restoring 8.0 do not end the spin; the audit's own main() hangs on main and 10.5.2. |
| Re-verification and tie-break (2026-10-31) | real, high | Minimal repro fails on 645d4fe14a20, 10.5.2 and 10.6.0, on and off; an own main() probe hangs flush, commit and NRT open on main, 10.6.0 and 9.12.3; controls and the one-line fix pass. |
| This doc | reproduced, fix validated | Gradle on 645d4fe14a20: the repro times out on stock and passes with the fix, as do TestTieredMergePolicy and the core lint checks. |
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 484 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,570 words
- Registry title
- TieredMergePolicy.setSegmentsPerTier accepts NaN, and the next IndexWriter flush, commit or NRT reopen then never returns: findMerges spins forever inside the synchronized IndexWriter.updatePendingMerges, blocking every other use of the writer (the other four double setters accept NaN too; with forceMergeDeletesPctAllowed = NaN, forceMergeDeletes() expunges nothing)
- Area
- index / TieredMergePolicy parameter validation (findMerges allowed-segment-count loop, IndexWriter.updatePendingMerges)
- Priority
- Low, rank 803: A NaN segmentsPerTier makes the default TieredMergePolicy hang the writer forever, but it needs a NaN config value nobody has reported in 15 years.
- Impact class
- hang
- Impact note
- Applications that set the default TieredMergePolicy's segmentsPerTier to NaN (for example a computed 0/0 or a configuration string 'NaN' read with Double.parseDouble), with maxMergedSegmentMB > 0 (the default 5 GB), get a first flush, commit or NRT reopen that never returns: one thread spins at full CPU inside the synchronized IndexWriter.updatePendingMerges, so commit, close, rollback, getDocStats, hasPendingMerges and further indexing on that writer block behind it, and neither Thread.interrupt() nor restoring a valid segmentsPerTier on the live policy ends it (only a JVM restart does); one document is enough, -ea and -da alike, every release 3.2.0 through 10.6.0 (10.6.0 by reading) and main; no index data is corrupted or exposed; separately, forceMergeDeletesPctAllowed = NaN makes forceMergeDeletes() a silent no-op.
- Summary
- You build an IndexWriter with the default TieredMergePolicy, call setSegmentsPerTier(Double.NaN), add one document and call flush() or commit(), expecting the setter to throw IllegalArgumentException as it does for every other value outside 'segmentsPerTier must be >= 2.0', or at least a flush that returns. Instead the setter stores NaN (its only check is 'v < 2.0', which is false for NaN) and flush() never returns: findMerges computes mergeFactor = (int) NaN = 0, the level size drops to 0, both exit tests of its while (true) loop stay false, and the thread spins forever while holding the IndexWriter lock, so every other thread that needs the writer blocks. The other four double setters (maxMergedSegmentMB, deletesPctAllowed, floorSegmentMB, forceMergeDeletesPctAllowed) accept NaN through the same ordered-only checks; with forceMergeDeletesPctAllowed = NaN, forceMergeDeletes() expunges nothing.
- Introduced
- f10d92398bc688b40498d9a4c765728ed446e050 (trunk, 'LUCENE-1076: new TieredMergePolicy'; Subversion era, no pull request; the issue was migrated as apache/lucene#2153, whose comments never mention NaN or segmentsPerTier validation) added setSegmentsPerTier with only the 'v < 2.0' check and the allowed-segment-count loop whose only exit was 'segCountLevel < segsPerTier', both false for NaN [read: git blame on main still attributes the check and the loop skeleton to it]; it shipped through the branch_3x backport ec542dd385e588075207cde69b9eefc072bcea3f ('LUCENE-1076: new TieredMergePolicy (merged from trunk)') [exec: 3.2.0 hangs, 3.1.0 has no TieredMergePolicy]. The current mechanism (mergeFactor = (int) NaN = 0, so levelSize falls to 0) comes from LUCENE-8392 (#9439; master 9433ab79e22, branch_7x 43481f26db0) and LUCENE-8393 (master 8e0d9849bd4, branch_7x 97833b729e3), both 7.5.0, and on main only #14165 (d2c69c1472c, mergeFactor = (int) segsPerTier); these changed how the loop spins, not whether it does [exec: 3.2.0 through 7.4.0, which have no mergeFactor, hang too]. Not a regression: no commit ever added a NaN check to the file (git log -G isNaN finds nothing) (2011-04-02 (trunk f10d92398bc); 2011-05-05 (branch_3x backport ec542dd385e))
- First affected version
- 3.2.0 [exec] (the first release with TieredMergePolicy; 3.1.0, the release before, has no such class: the repro fails to compile with 'cannot find symbol TieredMergePolicy')
- Affected versions
- every release that has TieredMergePolicy, 3.2.0 through 10.6.0 (10.6.0 by reading), and main ea57360912ed [exec, the follow-up's public-API main() (control commit with defaults returns; then setSegmentsPerTier(NaN), one document and a commit that must return within 20 s), -da (main also -ea): it hangs on 3.2.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.4.0, 7.5.0, 8.11.4, 9.12.3, 10.5.2 and main, the indexer RUNNABLE at about 2000 ms CPU per 2000 ms in that release's findMerges loop (3.2.0 :300, 3.6.2 :324, 4.0.0 :357, 4.10.4 :337, 5.5.5 :341, 6.6.6 :341, 7.4.0 :351, 7.5.0 :392, 8.11.4 :397, 9.12.3 :438, 10.5.2 :452, main :413), and a second thread calling a synchronized IndexWriter method (maxDoc in 3.x and 4.x, hasPendingMerges from 5.x) BLOCKED with the indexer as lock owner] [exec: the finder's JUnit fails 3 of 4 on main (-ea, -da), 10.5.2, 10.0.0 and 9.12.3; the independent reproduction's JUnit fails 7 of 9 on main (-ea, -da), 10.5.2 and 9.12.3] [read: the releases between the executed ones are inferred from the history (no NaN check was ever added; in every version of the loop each exit test is false for NaN under the default settings); upstream main c50bc9523db3, branch_10x and branch_10_6 have the same five checks, and no open PR changes them]. The forceMergeDeletesPctAllowed = NaN no-op was executed only on 9.12.3, 10.5.2 and main (same age by reading). Mechanism by era: 3.2.0-7.4.0 the loop's only exit is 'segCountLevel < segsPerTier'; from 7.5.0 mergeFactor = (int) Math.min(maxMergeAtOnce, NaN) = 0 and the added exit 'levelSize == maxMergedSegmentBytes' is taken only when maxMergedSegmentBytes is 0 [read, not executed]; from 9.x commit() also reaches findMerges through MergePolicy.findFullFlushMerges. All runs used JDK 25.0.3 on aarch64
- 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 and voted “not real” while agreeing with the root cause (the adjudication ruled it real), 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, follow-up prior-report record verdict new, confidence high): three scripted prior-report search runs of 65 queries each (GitHub issues and PRs 16, commits 3, downstream repositories 2, the dev@, java-user@, issues@ and commits@ lists 20, JIRA archive 4, web 20) on identifiers and setter messages, on symptoms (flush or commit hang, findMerges stuck, 100% CPU) and on the other setters and NaN validation in general, plus the finder's earlier run; 65 manual GitHub searches over apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr, apache/lucenenet and all of GitHub; 24 manual mailing-list queries; 8 JQL queries over LUCENE and SOLR; a content scan of 3,207 apache/lucene issues and PRs updated since 2025-01-01; all 410 open PRs listed with their changed files (10 touch merge-policy or IndexWriterConfig files; the merge-policy patches of #92, #681, #13409, #14837, #16655 and #16847 were read; none changes a setter check, mergeFactor or NaN handling); git log -S isNaN over the merge-policy sources; 25 hits read, none the same bug. Not fixed: upstream main (head c50bc9523db3, TieredMergePolicy.java last changed 2026-01-23), branch_10x and branch_10_6 have the same five checks. Nearest item, another class: the #14412 review comment r2019386671 (jpountz, 2025-03-30, merged), asking to keep LRUQueryCache's NaN-rejecting skipCacheFactor check; it shows that maintainers want setters to reject NaN, and is not a report of this bug. Context only, none mentions NaN: LUCENE-1076 (#2153, origin of the class and the check), LUCENE-8392 (#9439) and #14165 (mergeFactor), LUCENE-7005 (#8062, setter tweaks), LUCENE-8385 (#9432) and LUCENE-8394 (#9441, the same loop's other edge cases), LUCENE-4269 (#5338, a commit that never returns in the removed BalancedSegmentMergePolicy)
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails through gradle in a scratch worktree, and through a standalone runner with assertions on and off; with the recommended fix it passes, as doTestTieredMergePolicy(17 tests, 1 nightly skipped) and the core module's format, ECJ and forbidden-apis checks. Releases 3.2.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.4.0, 7.5.0, 8.11.4, 9.12.3, 10.5.2 and 10.6.0 hang; 3.1.0 has noTieredMergePolicy. Upstream main2f424fd428da(2026-10-30) has blob-identicalTieredMergePolicy.java,IndexWriter.javaandMergePolicy.java[read].
- Finding record
lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:67 (at ea57360912ed) (@lucene.experimental), :80 (segsPerTier = 8.0), :102-108 (setMaxMergedSegmentMB: 'v < 0.0', then (long) v, so NaN is stored as 0 bytes), :123 (setDeletesPctAllowed javadoc 'Values must be between 0 and 50'), :138-142 ('v <= 0 || v > 50'), :168-175 (setFloorSegmentMB: 'v <= 0.0', NaN stored as 0 bytes), :195-201 (setForceMergeDeletesPctAllowed: 'v < 0.0 || v > 100.0'), :214-224 (setSegmentsPerTier: 'Default is 8.0', only 'if (v < 2.0)', message 'segmentsPerTier must be >= 2.0'), :400-413 (mergeFactor = (int) segsPerTier; the while (true) loop with exits 'segCountLevel < segsPerTier' and 'levelSize == maxMergedSegmentBytes'; levelSize = Math.min(maxMergedSegmentBytes, levelSize * mergeFactor)), :947 and :960 (findForcedDeletesMerges compares pctDeletes with forceMergeDeletesPctAllowed)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2442-2446 (at ea57360912ed) (maybeMerge -> updatePendingMerges), :2454 ('private synchronized MergePolicy.MergeSpecification updatePendingMerges'), :2508 (mergePolicy.findMerges), :4378-4379 (flush -> maybeMerge); MergePolicy.java:604 (findMerges 'is always synchronized on the IndexWriter instance')lucene/core/src/test/org/apache/lucene/index/TestTieredMergePolicy.java:759-793 (at ea57360912ed) (testSetters: +Infinity accepted at :765 and :782, negative values rejected, no NaN anywhere)SECURITY.md lines 42 (configuration values 'are trusted as given'), 46 (a change that prevents an endless loop is welcome as a public issue or PR), 50 (in-scope definition)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:77, :79, :80, :103, :139, :169, :196, :219-225, :400-413, :947a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2442-2446, :2454, :2496, :2508, :4378-4379, :6353-6359a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:602-605, :693
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:1859-1861; lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:143 (default merge policy)
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:67, :213-225; lucene/core/src/test/org/apache/lucene/index/TestTieredMergePolicy.java:759-793a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:178; lucene/core/src/java/org/apache/lucene/search/similarities/BM25Similarity.java:53-60SECURITY.md line 46
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:178
- TestTieredMergePolicySegmentsPerTierNaN.java (one test class):
repro/LFV-447/src/org/apache/lucene/index/TestTieredMergePolicySegmentsPerTierNaN.java(42 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the minimal repro's output.
assets/LFV-447/bug.png(484 KB)
