All findings · Lucene finding · LFV-1210 · cms-updatemergethreads-resamples-verbose-null-message

ConcurrentMergeScheduler throws NullPointerException when merge logging is switched on during updateMergeThreads

If merge logging is switched on while ConcurrentMergeScheduler.updateMergeThreads runs, the method throws NullPointerException: its first "is logging on?" check said no, so it never created its log buffer, and a later check says yes and uses it. setForceMergeMBPerSec(10.0) fails this way on main and in every release checked since 5.0.0.

Summary

Priority
Low, rank 801Turning merge logging on at runtime can make ConcurrentMergeScheduler throw NullPointerException from its IO-throttle setters or at a merge start or finish, and at a merge start it can end in a tragic writer close, in every release since 5.0.0. It needs a logger-backed InfoStream whose level changes inside a call that lasts microseconds, so it is rare.
Impact
minor
Since
5.0.0 (LUCENE-6119, 2015); 4.10.x is cleanintroduced 2015-01-05 · over 11 years
Affected
5.0.0 to 10.6.0 and main (10 versions run, all fail); not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of updateMergeThreads' logging, witnessed by an executable repro

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

updateMergeThreads asks "is merge logging on?" at three places in one call (the second once per running merge). It creates its log buffer only if the first answer is yes, but uses the buffer whenever a later answer is yes. A no-then-yes sequence dereferences null.

Background: merge-scheduler logging

  1. IndexWriterConfig.setInfoStream installs an InfoStream, Lucene's debug log (default: NO_OUTPUT, always off). Before building a message, code asks isEnabled(component); the merge scheduler asks for "MS" through verbose().
  2. The answer may change while the writer runs: JavaLoggingInfoStream answers Logger.isLoggable(level), so raising a java.util.logging level switches merge logging on.
  3. ConcurrentMergeScheduler.updateMergeThreads re-applies the IO rate of every running merge and logs one message per call. It runs in the three IO-throttle setters, at every merge start and finish, and on close.
Diagram of the bug: one call of ConcurrentMergeScheduler.setForceMergeMBPerSec(10.0) runs updateMergeThreads. Its first check of isEnabled("MS"), at line 354, answers false, so the log buffer is set to null. Merge logging is then switched on. The last check, at line 437, answers true, and message.toString() on the null buffer throws NullPointerException at line 438. The forced-merge rate 10.0 is already stored; the next call finds logging on at both checks and logs its message. With the fix, the answer is read once per call and the call returns normally.
One setForceMergeMBPerSec(10.0) call: updateMergeThreads asks whether merge logging is on at line 354 (no, so no log buffer), logging is switched on, it asks again at line 437 (yes) and calls message.toString() on the missing buffer: NullPointerException. 10.0 is already stored; the next call logs normally. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Give IndexWriterConfig an InfoStream that follows a log level, such as Lucene's JavaLoggingInfoStream, with merge-scheduler logging (component "MS") off, and use a ConcurrentMergeScheduler.
  2. Switch merge logging on while the writer is open. If the switch lands inside one updateMergeThreads call (it runs in setForceMergeMBPerSec, enableAutoIOThrottle, disableAutoIOThrottle and at every merge start and finish), you expect the call to finish and logging to start, as with a constant InfoStream and as in 4.10.
  3. Instead the call asked twice: at line 354 the answer was false, so the log buffer stayed null; at line 437 it was true, so it calls message.toString(): NullPointerException ... because "message" is null. In the repro setForceMergeMBPerSec(10.0) throws after storing 10.0, with assertions on and off.
  4. At a merge start the same exception makes merge() hand back the merge that is already running. A probe then saw a second merge of the same segments and a tragic writer close; that hand-back is LFV-208's mechanism, which this bug can trigger.

Who is affected

Only applications with a non-default InfoStream whose answer changes at runtime (Lucene's JavaLoggingInfoStream or a logger-backed one of their own) that switch merge logging on while the writer runs, and only when the switch lands inside one short call. The default InfoStream is never affected. Present since 5.0.0.

Mechanism, terms and numbers358 words

ConcurrentMergeScheduler.updateMergeThreads builds one log message per call in a StringBuilder. It creates the builder only if its first verbose() check is true and sets it to null otherwise (ConcurrentMergeScheduler.java:353-364 at 5adff78bb168), then calls verbose() again before appending one line per running merge (:391-397) and before logging the message (:437-438). verbose() re-reads infoStream.isEnabled("MS") on every call (MergeScheduler.java:93-95). If merge logging is switched on between the first check and a later one, for example by raising a log level under Lucene's own JavaLoggingInfoStream, the later check dereferences the null builder and the call throws NullPointerException. It is reachable from the public setters setForceMergeMBPerSec, enableAutoIOThrottle and disableAutoIOThrottle and from every merge start and finish. The window is one short call, so it is rare. The shared builder came with LUCENE-6119 (5.0.0); in 4.x each check guarded its own log call, and the same scenario returns normally on 4.10.3 and 4.10.4.

The bug, step by step (main 5adff78bb168)

  1. setForceMergeMBPerSec stores the value and calls updateMergeThreads() (ConcurrentMergeScheduler.java:199-202); enableAutoIOThrottle (:214-218) and disableAutoIOThrottle (:225-228) do the same. merge() calls it right after newMergeThread.start() (:590-591), runOnMergeFinished in its finally block (:691-697), and merge(CLOSING) at :544-547.
  2. Check 1 (:354): if (verbose()) { message = new StringBuilder(); ... } else { message = null; } (:363). Logging is off, so message is null.
  3. Merge logging is switched on now.
  4. Check 2 (:391, once per running merge thread): if (verbose()) is true, then message.append('\n') (:397): NPE, before that merge's rateLimiter.setMBPerSec(newMBPerSec) (:435); with the switch before the loop, this pass sets no merge's new rate.
  5. Check 3 (:437, reached even with no merge thread, as in the repro): if (verbose()) is true, then message(message.toString()) (:438): NPE.

True to false is harmless: the builder exists and is just not logged. Each check on its own follows the documented idiom if (verbose()) { message(...) } (MergeScheduler.java:83-92); the method breaks it by creating its data under one answer and using it under another.

Exposure and likelihood333 words
  • Who is hit. Applications that install an InfoStream whose isEnabled("MS") answer can change: Lucene's JavaLoggingInfoStream (advertised in MIGRATE.md) or their own logger-backed one, with merge logging switched on while the writer is open. The default NO_OUTPUT (always false) and PrintStreamInfoStream (always true) are not affected.
  • Window. The switch must land inside one updateMergeThreads call, a narrow window (its duration was not measured; the method is synchronized, but that only orders the scheduler's own calls, not a Logger.setLevel on another thread); the call runs at every merge start and finish, on close, and in the three setters. A supporting probe with an unmodified JavaLoggingInfoStream and another thread that keeps toggling the "MS" logger's level hit the NPE after 34 setter calls; that toggling rate is not realistic, so it only shows reachability.
  • Setter. A loud NPE after the value is stored. With merges running, the merges the loop had not yet reached (all of them if logging switched on before the loop) keep their old rate until the next updateMergeThreads call.
  • Merge start. The NPE lands in merge()'s catch (Throwable t) { mergeSource.onMergeFinished(merge); throw t; } (:592-594), which hands back a merge whose thread already runs. A supporting probe then saw a second merge of the same segments registered and a tragic writer close (-ea: AssertionError at IndexWriter._mergeInit; -da: NPE at IndexWriter.closeMergeReaders). A tragic close rolls the writer back (IndexWriter.java:6097-6103), which discards changes since the last commit; that is read from the code, the probe did not check documents. That hand-back is LFV-208's mechanism, with its own root cause and fix site; this bug is one more trigger for it.
  • Merge finish. The NPE skips notifyAll() and ends the merge thread through handleMergeException after the merge completed; stalled indexing threads wait at most 250 ms per round (doStall), so nothing hangs.
  • Likelihood. No scale or index content is involved, and no public report of it was found since 2015: rare.

Minimal repro

One JUnit test method that calls only public methods. Its InfoStream (a subclass of the @lucene.internal InfoStream) answers false once after it is armed and true afterwards, a deterministic stand-in for a log level raised mid-call; then cms.setForceMergeMBPerSec(10.0), with no document and no merge. Single-threaded, about 0.1 s; it fails with assertions on and off.

TestConcurrentMergeSchedulerVerboseTurnsOn.java (lucene/core, package org.apache.lucene.index, 1 test)55 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.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.InfoStream;

public class TestConcurrentMergeSchedulerVerboseTurnsOn extends LuceneTestCase {

  static final class TurnsOnInfoStream extends InfoStream {
    boolean armed, enabled;

    @Override
    public boolean isEnabled(String component) {
      boolean current = enabled;
      enabled = armed;
      return current;
    }

    @Override
    public void message(String component, String message) {}

    @Override
    public void close() {}
  }

  public void testLoggingTurnedOnDuringSetForceMergeMBPerSec() throws Exception {
    TurnsOnInfoStream infoStream = new TurnsOnInfoStream();
    ConcurrentMergeScheduler cms = new ConcurrentMergeScheduler();
    Directory dir = newDirectory();
    IndexWriter w =
        new IndexWriter(
            dir, newIndexWriterConfig().setMergeScheduler(cms).setInfoStream(infoStream));
    infoStream.armed = true;
    // expected: returns normally; actual: NullPointerException in updateMergeThreads
    cms.setForceMergeMBPerSec(10.0);
    w.close();
    dir.close();
  }
}
Command
cp $REPRO/TestConcurrentMergeSchedulerVerboseTurnsOn.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestConcurrentMergeSchedulerVerboseTurnsOn -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false (the production setting): the same failure
Expected
setForceMergeMBPerSec(10.0) stores 10.0 MB/s and returns normally; logging is on from then on.
Observed
NullPointerException: Cannot invoke "java.lang.StringBuilder.toString()" because "message" is null at updateMergeThreads(ConcurrentMergeScheduler.java:438), with assertions on and off.

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

More files, environment, full output234 words

TestConcurrentMergeSchedulerVerboseTurnsOn (one method, extends LuceneTestCase, package org.apache.lucene.index of the lucene/core tests). Its InfoStream answers false until armed is set; after that its first answer is false and every later one is true, so the switch lands between updateMergeThreads' first and last check, as a log level raised at that moment would.

It is the hunt's minimal repro with two changes so it compiles in Lucene's build: the writer is closed explicitly instead of in try-with-resources (javac -Werror rejects an unreferenced resource under the [try] lint) and the comment line fits 100 columns. Behaviour is unchanged. Seeds do not matter: this file failed with DEADBEEF, and the hunt's versions of it with DEADBEEF, 5EED0E41, 5EED0D41, 5A5A1E and A0D17E41. As the regression test, the same method fits into TestConcurrentMergeScheduler as is.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 5.0.0 to 10.6.0: fail; 4.10.3, 4.10.4: same scenario passes
jdkOpenJDK 25.0.3
osLinux aarch64 (nothing platform-specific)
Command (environment)
cp $REPRO/TestConcurrentMergeSchedulerVerboseTurnsOn.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestConcurrentMergeSchedulerVerboseTurnsOn -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false

Observed output

# gradle on main 5adff78bb168, -Ptests.seed=DEADBEEF (the same with -Ptests.asserts=false)
TestConcurrentMergeSchedulerVerboseTurnsOn > testLoggingTurnedOnDuringSetForceMergeMBPerSec FAILED
    java.lang.NullPointerException: Cannot invoke "java.lang.StringBuilder.toString()" because "message" is null
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:8F105C7FBB625DBA]:0)
        at org.apache.lucene.index.ConcurrentMergeScheduler.updateMergeThreads(ConcurrentMergeScheduler.java:438)
        at org.apache.lucene.index.ConcurrentMergeScheduler.setForceMergeMBPerSec(ConcurrentMergeScheduler.java:201)
        at org.apache.lucene.index.TestConcurrentMergeSchedulerVerboseTurnsOn.testLoggingTurnedOnDuringSetForceMergeMBPerSec(TestConcurrentMergeSchedulerVerboseTurnsOn.java:51)

# release jars: the same NPE at updateMergeThreads :440 (10.6.0), :439 (10.5.2, 10.0.0), :431 (9.12.3), :407 (8.11.4), :406 (7.7.3, 6.6.6), :370 (5.5.5), :343 (5.0.0)

Why it looks unintended

  • isEnabled promises no stable answer, and Lucene ships JavaLoggingInfoStream, whose answer follows a runtime log-level change.
  • isEnabled was added (#4672) so logging can be switched on and off "live"; only the caller can read the answer once per call.
  • In 4.x each check guarded its own log call; 4.10.3 and 4.10.4 pass the same scenario.
The full argument189 words
  • Contract. InfoStream.isEnabled is documented only as "returns true if messages are enabled and should be posted to message" (InfoStream.java:53-54). verbose() is documented as "usually used in conjunction with message(String)", with the paired if (verbose()) { message(...) } idiom (MergeScheduler.java:83-92). IndexWriterConfig.setInfoStream(InfoStream) is public and accepts any non-null InfoStream (IndexWriterConfig.java:430).
  • Design history. LUCENE-3598 (#4672, 2011) introduced isEnabled for logging that "is enabled or disabled dynamically": "Using this you could enable/disable logging live by e.g. the log4j management console" (Uwe Schindler, in the issue description, answering a request by Shai Erera). Robert Muir supported it because "it makes it possible for someone to turn on/off infostream \"live\" themselves".
  • Tests. The 15 isEnabled overrides in Lucene's test sources (14 in lucene/core/src/test, for example TestConcurrentMergeScheduler.java:801-803, and 1 in lucene/test-framework/src/test) and the 4 InfoStreams in the test framework's own code (RandomIndexWriter, BaseMergePolicyTestCase, NullInfoStream, FailOnNonBulkMergesInfoStream) each answer a fixed value per component, so no test switches logging during a call and nothing asserts the current behaviour.
  • History. The shared builder came with LUCENE-6119 (#7181, 2015); its 28 comments never discuss it.
The devil's-advocate check200 words

The argument (an independent reviewer from another model family, OpenAI GPT-5.6, which reproduced the NPE and agreed on the root cause, still judged it not real, "not a supported Lucene API defect"): InfoStream is @lucene.internal, a "Debugging API" (InfoStream.java:23-28), and so is Lucene's own JavaLoggingInfoStream (JavaLoggingInfoStream.java:30); MergeScheduler is @lucene.experimental. A public setter that takes an internal type does not by itself make a stable contract, and nothing promises support for an isEnabled answer that changes within one call. In #4672 Robert Muir also said implementers "are responsible for their own implementation (if they want to make stuff volatile or synced, good for them)".

Why it does not settle it. Lucene ships JavaLoggingInfoStream and points users to it (MIGRATE.md:1162-1163), and its answer follows Logger.setLevel; live switching is the stated purpose of isEnabled; and an InfoStream cannot keep its answer stable across a call whose start and end it cannot see. Volatile or synchronized state inside the InfoStream does not help a caller that asks twice. What survives: both types are internal, so a maintainer may well call the change a defensive cleanup rather than a bug; the fix is the same.

Suggested fix

Recommended A. Read verbose() once per call (recommended)

Read verbose() once at the top of updateMergeThreads into final boolean verbose and use that local at all three check sites (one file, +4/-3). The repro then passes with assertions on and off, and TestConcurrentMergeScheduler and TestMergeSchedulerExternal pass (24 of 24 tests).

+    final boolean verbose = verbose();
     StringBuilder message;
-    if (verbose()) {
+    if (verbose) {
       message = new StringBuilder();
 ...
-      if (verbose()) {
+      if (verbose) {
         long mergeStartNS = merge.mergeStartNS;
 ...
-    if (verbose()) {
+    if (verbose) {
       message(message.toString());
All options and trade-offs3 options ยท 351 words

Option A is the smallest change and keeps the documented idiom per call. B is equivalent in behaviour; C trades the null for extra work or a headless message. None of them changes LFV-208: its fix (do not hand back a merge whose thread already started) is separate and it has other triggers.

A. Read verbose() once per call (recommended) (validated by the checks)

At the top of updateMergeThreads, final boolean verbose = verbose();, and if (verbose) at the allocation (:354), the per-thread lines (:391) and the final log call (:437).

+    final boolean verbose = verbose();
     StringBuilder message;
-    if (verbose()) {
+    if (verbose) {
       message = new StringBuilder();
 ...
-      if (verbose()) {
+      if (verbose) {
         long mergeStartNS = merge.mergeStartNS;
 ...
-    if (verbose()) {
+    if (verbose) {
       message(message.toString());

Trade-offs

Logging switched on mid-call starts with the next call, so one updateMergeThreads message is not logged; logging switched off mid-call still logs that call's one message, which today's code would drop. One isEnabled call per invocation instead of N+2 (N = running merges).

What the checks validated

Gradle in a scratch worktree at 5adff78bb168: the repro fails without the fix and passes with it, with assertions on and off; with the fix, TestConcurrentMergeScheduler (21 tests), TestMergeSchedulerExternal (2) and the repro pass (24 of 24) and checkGoogleJavaFormat is clean. The hunt, the independent reproducer and the cross-model reviewer each validated the same change.

B. Guard the later uses with message != null (not run)

Replace the two later if (verbose()) checks with if (message != null).

Trade-offs

Same behaviour as A, including the switch-off case (the builder doubles as the flag); slightly less obvious, because the guard no longer says "is logging on".

What the checks validated

Not run.

C. Always create the builder (not run)

Create the StringBuilder unconditionally.

Trade-offs

Avoids the null, but the method then either formats its header on every call with logging off, or logs a message without its header after a mid-call switch. Not recommended.

What the checks validated

Not run.

Downstream note

Downstream products that bridge Lucene's InfoStream to their logging framework and let operators change log levels at runtime could see this exception from merges or IO-throttle settings; queries, documents and index files cannot reach it.

How it was found

A proof-obligation audit of updateMergeThreads: every use of its log buffer must be guarded by the same "is logging on?" answer that created it. The method asks verbose() again before each use, so the obligation fails when the answer turns from no to yes mid-call. A one-method JUnit test that calls only public methods then witnessed the NPE.

The method in full101 words

The audit took the logging idiom that MergeScheduler documents (if (verbose()) { message(...) }) and checked updateMergeThreads, which splits one message across several checks. In updateMergeThreads the buffer is created under the first answer and dereferenced under later, separately read answers, and verbose() re-reads isEnabled("MS") each time, so nothing ties them together.

An executable repro that calls only public methods witnessed it on main with assertions on and off. An independent reproducer, an executed cross-model review (OpenAI GPT-5.6, refute lens), a devil's-advocate verifier and an adjudication then checked it, and a version sweep placed it in LUCENE-6119 (5.0.0).

Prior reports, history and checks

Prior reports

No prior report found in apache/lucene issues, PRs and commits (incl. the JIRA archive), the mailing lists, downstream trackers or the web (searched 2026-11-03). Nearest: #7181 (LUCENE-6119, which added the buffer) and #4672 (LUCENE-3598, live isEnabled).

Searches3 searches

Hits were background only: #7181 (LUCENE-6119, added the builder; its 28 comments never discuss it), #4672 (LUCENE-3598, design of live isEnabled), #13593 with open PR #16889 (merge-thread cap, other code), #14964 (a sandbox scheduler that calls super.updateMergeThreads) and #15761 (the flaky test linked to LFV-208: no NPE, no updateMergeThreads, no InfoStream). Upstream main 1c2072f0284 (2026-11-02) still has the same lines.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive)2026-11-03; re-checked the same daytwo scripted prior-report searches (45 queries, then 26 issue/PR, 3 commit and 5 JIRA-archive queries) and manual searches, including the exact JDK message and the three setter namesno report of this NPE or of the repeated checks
Open and recent PRs2026-11-03the changed files of all 409 open apache/lucene PRs and of the 42 PRs updated since 2026-11-01none changes updateMergeThreads; none of the recent ones touches ConcurrentMergeScheduler.java, MergeScheduler.java or an InfoStream class
Mailing lists, downstream trackers, web2026-11-0324 queries over dev@, java-user@, issues@ and commits@; 4 downstream-repository queries and manual searches of downstream search engines and all of GitHub; 6 web queriesno report

History

Introduced by LUCENE-6119 (5.0.0, 2015), which put one shared log buffer into updateMergeThreads and added the three setters that call it. Every release run since fails, through 10.6.0 and main; 4.10.3 and 4.10.4 pass. Not fixed upstream.

Versions tested6 rows

41fcc722ff0 (trunk, svn r1649532) and 89fde6f3f06 (branch_5x, svn r1649539), LUCENE-6119 "CMS dynamically rate limits IO writes of each merge depending on incoming merge rate" (#7181, Michael McCandless, 2015-01-05, Subversion era, no pull request). Found with git log -S'message = null;', git blame -w -M -C -C of the three sites and git log -L over the method; since then only LUCENE-7700 (2017) and the 2020 reformat touched the method body, neither the checks (LUCENE-9345, 2020, changed the line after the method, not its body). git tag --contains of the two introducing commits lists 99 release tags together (84 and 15), 5.0.0 to 10.6.0; the history clone has no tags for 8.8.2 to 8.11.4, so 8.8.2 to 8.11.3 are inferred (8.11.4 was run).

5.x to 8.x ran the repro's steps in main() form, because LuceneTestCase moved packages in 9.1.0. 4.10.x ran a same-scenario check with one merge running that calls the protected updateMergeThreads() directly (4.x's public setMergeThreadPriority also reaches it but was not used); logging was seen switching on, so the pass is not vacuous.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle: fails with assertions on and off (:438); passes with fix A
10.6.0 affectedfails with assertions on and off (:440)
10.5.2, 10.0.0, 9.12.3 affectedfail, the same NPE (:439, :439, :431)
5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4 main() formfail, the same NPE (5.0.0 with assertions on and off)
4.10.3, 4.10.4 same-scenario checkthe same scenario returns normally (logging seen switching on)
upstream main 1c2072f0284 (2026-11-02) readthe same lines; not fixed

Independent checks

A finder, an independent reproducer, an executed cross-model review (OpenAI GPT-5.6: reproduced it, agreed on the cause, judged it not real on contract grounds), a devil's-advocate verifier, an adjudication, an audit and a final repro check reproduced it. This doc's gradle runs reproduced it on main and validated fix A.

Checks and verdicts221 words

The cross-model review ran on main ea57360912ed, whose ConcurrentMergeScheduler.java and MergeScheduler.java are identical to 5adff78bb168's, and its test was re-run on 5adff78bb168 (fails). The adjudication weighed its contract argument against the shipped JavaLoggingInfoStream and the design of isEnabled and confirmed the bug as Low.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedThe test fails on main (assertions on and off), 10.6.0 and 9.12.3; fix A passes it and TestConcurrentMergeScheduler (21 tests).
independent reproducer real, LowOwn test, written from the description alone, fails on main, 10.6.0, 10.5.2 and 9.12.3; its own one-local fix passes.
cross-model review, refute lens (OpenAI GPT-5.6, executed) not real (contract only), highReproduced it: its own 2-test class fails for the false-to-true stream and passes for the constant control, and passes with fix A. Root cause agrees; it argued that InfoStream is @lucene.internal.
devil's-advocate verifier real, LowSteelmanned @lucene.internal and the #4672 remark and rebutted them; an unmodified JavaLoggingInfoStream with a level toggled from another thread hit the NPE.
adjudication, separate re-runs, audit and final repro check confirmed, LowRe-runs on main fail at :438 (seeds 5A5A1E, 0DDBA11, 5EED0E41, 5EED0D41, A0D17E41); the fix passes.
this doc reproduced; fix A validatedGradle in a scratch worktree at 5adff78bb168, assertions on and off.

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

Finding record

LFV-1210, snapshot of 2026-11-06

Registry title
ConcurrentMergeScheduler.updateMergeThreads throws NullPointerException when merge logging is switched on during the call, because it allocates its log buffer only if its first verbose() check is true and then checks verbose() again before using the buffer
Area
index / ConcurrentMergeScheduler (updateMergeThreads infoStream logging; IO-throttle setters, merge start and finish)
Priority
Low, rank 801: Turning merge logging on at runtime can make ConcurrentMergeScheduler throw NullPointerException from its IO-throttle setters or at a merge start or finish, and at a merge start it can end in a tragic writer close, in every release since 5.0.0. It needs a logger-backed InfoStream whose level changes inside a call that lasts microseconds, so it is rare.
Impact class
minor
Impact note
Hits only applications that install a non-default InfoStream whose isEnabled("MS") answer changes at runtime (for example Lucene's own JavaLoggingInfoStream when the logger level is raised, or a logger-backed InfoStream in a downstream product) and switch merge logging on exactly while one updateMergeThreads call runs (setForceMergeMBPerSec, enableAutoIOThrottle, disableAutoIOThrottle, a merge start or a merge finish): the call throws NullPointerException with -ea and -da; from a setter the new value is stored but, with merges running, not applied to them in that pass; at merge start the exception makes merge() hand the running merge back, which is LFV-208's mechanism and later closes the writer with a tragic exception; no scale is needed, the off-to-on change must land in a window of microseconds, and every release since 5.0.0 is affected.
Summary
You give IndexWriterConfig an InfoStream that follows a logger level, and you switch merge-scheduler logging on while the writer runs, at a moment when ConcurrentMergeScheduler.updateMergeThreads is running (it runs in setForceMergeMBPerSec, enableAutoIOThrottle and disableAutoIOThrottle, and at every merge start and finish). You expect the call to finish normally and logging to start, as it does with a constant InfoStream and as it did in 4.x. Instead the method, which skipped creating its log buffer because its first verbose() check said false, sees true at a later check and throws NullPointerException ('Cannot invoke "java.lang.StringBuilder.toString()" because "message" is null').
Introduced
41fcc722ff07 on trunk (svn r1649532) and 89fde6f3f069 on branch_5x (svn r1649539, how 5.0.0 got it): LUCENE-6119 'CMS dynamically rate limits IO writes of each merge depending on incoming merge rate' by mikemccand (no pull request; the JIRA issue is now #7181). It rewrote updateMergeThreads around one StringBuilder log buffer that is allocated only when the first verbose() check is true ('message = null' otherwise) and then appended to and flushed under separate verbose() checks, and added the public setters setForceMergeMBPerSec, enableAutoIOThrottle and disableAutoIOThrottle that call it [read: git log -S'message = null;' and -S'StringBuilder message;', git blame -w -M -C -C of the three sites on 5adff78bb168, git log -L over updateMergeThreads in the history clone] [exec: 4.10.3 and 4.10.4 pass the same-scenario check, 5.0.0 fails] (2015-01-05 (41fcc722ff0 at 14:28:28Z; 89fde6f3f06 at 15:00:32Z))
First affected version
5.0.0 [exec] (released 2015-02-19); 4.10.3 and 4.10.4 are not affected: each verbose() check there guards its own message() call and there is no buffer [exec: the same-scenario check returns normally and logs]
Affected versions
every release from 5.0.0 through 10.6.0, and main 5adff78bb168 [exec: the same NullPointerException in updateMergeThreads on 5.0.0 (-ea, -da), 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.12.3, 10.0.0, 10.5.2, 10.6.0 (-ea, -da) and main (-ea, -da); 5.x-8.x run the repro's steps in main() form because LuceneTestCase moved in 9.1.0] [read: git tag --contains lists 99 release tags from 5.0.0 to 10.6.0 in the history clone; 8.8.2-8.11.3 are not tagged there and are inferred between executed failing releases]; not fixed upstream (main tip 1c2072f0284, 2026-11-02, has the same lines; the newest commit on the file is 54283150a81, #16255, author date 2026-06-14, outside updateMergeThreads). A regression relative to 4.x. Not new in 10.6 (10.5.2 fails). All runs used JDK 25 on one aarch64 Linux host
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (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: proof-obligation audit
Upstream and prior reports
none found (2026-11-03, on top of the finder's, the intended-behaviour check's and the adjudication's searches; rechecked the same day): two scripted prior-report searches (the second: 26 GitHub issue/PR, 3 commit, 4 downstream, 24 mailing-list queries over dev@, java-user@, issues@ and commits@, 5 JIRA-archive and 6 web queries; the finder's first: 45 queries), manual GitHub REST and GraphQL searches (apache/lucene, Elasticsearch, OpenSearch, Solr and all of GitHub, including the exact JDK message text and the three setter names), manual JIRA and list searches, the changed files of all 409 open apache/lucene PRs and of the 42 PRs updated since 2026-11-01 (none touches ConcurrentMergeScheduler.java, MergeScheduler.java or an InfoStream class), and git history: no issue, PR, commit, JIRA entry, list thread, downstream issue or web page reports a NullPointerException in updateMergeThreads or discusses its repeated verbose() checks. Nearest items, none of them this bug: #7181 (LUCENE-6119, the change that added the buffer; its 28 comments never discuss it), #4672 (LUCENE-3598, design background: isEnabled was added so logging can be switched on and off live), #13593 with open PR #16889 (merge-thread cap, other code), #14964 (a sandbox scheduler that calls super.updateMergeThreads), #15761 (the flaky-test report linked to LFV-208: no NPE, no updateMergeThreads, no InfoStream).
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT): gradle in a scratch worktree for this doc (the repro fails with assertions on and off and passes with fix A; TestConcurrentMergeScheduler, TestMergeSchedulerExternal and the repro pass with fix A, 24 of 24; checkGoogleJavaFormat clean) and standalone JUnit runs; release jars 4.10.3 and 4.10.4 (pass), 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (fail). OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:199-202 (at 5adff78bb168) (setForceMergeMBPerSec stores the value, then calls updateMergeThreads; enableAutoIOThrottle :214 and disableAutoIOThrottle :225 do the same), :318 (updateMergeThreads), :353-364 ('StringBuilder message;', 'if (verbose()) { message = new StringBuilder(); ... }' at :354, 'message = null;' at :363), :391-397 (per merge thread: 'if (verbose())', then message.append('\n') at :397, before rateLimiter.setMBPerSec at :435), :437-438 ('if (verbose()) { message(message.toString()); }', reached with no merge thread), :586-595 (merge(): newMergeThread.start() :590, updateMergeThreads() :591, catch (Throwable t) { mergeSource.onMergeFinished(merge); throw t; } :592-594)
  • lucene/core/src/java/org/apache/lucene/index/MergeScheduler.java:83-95 (at 5adff78bb168) (verbose() javadoc: 'usually used in conjunction with message(String)', the 'if (verbose()) { message(...) }' idiom; verbose() returns infoStream != null && infoStream.isEnabled("MS") and is re-evaluated on every call)
  • lucene/core/src/java/org/apache/lucene/util/InfoStream.java:23-28 (at 5adff78bb168) ('Debugging API', @lucene.internal), :53-54 (isEnabled: 'returns true if messages are enabled and should be posted to message', no stability promise)
  • lucene/core/src/java/org/apache/lucene/util/JavaLoggingInfoStream.java:84-85 (at 5adff78bb168) (isEnabled returns getLogger(component).isLoggable(level), so its answer follows a runtime level change)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:430 (at 5adff78bb168) (public setInfoStream(InfoStream), accepts any non-null InfoStream)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:199-202, (at 5adff78bb168) :214-218, :225-228, :318, :353-364, :391-397, :435, :437-438, :544-547, :590-594, :691-697
  • lucene/core/src/java/org/apache/lucene/index/MergeScheduler.java:83-95 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/util/InfoStream.java:33-54 (at 5adff78bb168) (NO_OUTPUT, isEnabled)
  • lucene/core/src/java/org/apache/lucene/util/JavaLoggingInfoStream.java:84-85 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:430 (at 5adff78bb168)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:435, (at 5adff78bb168) :590-594, :647-654, :691-697, :720-756
  • lucene/MIGRATE.md:1162-1163 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2567-2568, (at 5adff78bb168) :6097-6103 (tragic close -> rollbackInternal; rollback discards changes since the last commit); ConcurrentMergeScheduler.java:318 (synchronized)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/InfoStream.java:23-28, (at 5adff78bb168) :53-54; JavaLoggingInfoStream.java:26-31 (@lucene.internal at :30); lucene/MIGRATE.md:1162-1163
  • lucene/core/src/java/org/apache/lucene/index/MergeScheduler.java:83-95 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:430 (at 5adff78bb168)
  • https://github.com/apache/lucene/issues/4672 (LUCENE-3598: issue description by Uwe Schindler; comment by Robert Muir; read 2026-11-03)
  • https://github.com/apache/lucene/issues/7181 (LUCENE-6119)
  • Lucene main 5adff78bb168 grep of 'public boolean isEnabled(String' outside src/java: 15 test overrides (14 lucene/core/src/test, 1 lucene/test-framework/src/test), plus 4 in lucene/test-framework/src/java (RandomIndexWriter.java:593, BaseMergePolicyTestCase.java:184, NullInfoStream.java:37, FailOnNonBulkMergesInfoStream.java:30), each a fixed answer per component (e.g. lucene/core/src/test/org/apache/lucene/index/TestConcurrentMergeScheduler.java:801-803, TestInfoStream.java:98, :113-114)
Diagrams
  • lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:199-202, (at 5adff78bb168) :353-364, :437-438

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

Repro files

About the diagrams

The check lines, answers and stored value come from a probe on main 5adff78bb168 with assertions on and off (and with the fix shadowed in).

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.