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.
IndexWriterConfig.setInfoStreaminstalls anInfoStream, Lucene's debug log (default:NO_OUTPUT, always off). Before building a message, code asksisEnabled(component); the merge scheduler asks for "MS" throughverbose().- The answer may change while the writer runs:
JavaLoggingInfoStreamanswersLogger.isLoggable(level), so raising ajava.util.logginglevel switches merge logging on. ConcurrentMergeScheduler.updateMergeThreadsre-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.
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.- Give
IndexWriterConfiganInfoStreamthat follows a log level, such as Lucene'sJavaLoggingInfoStream, with merge-scheduler logging (component "MS") off, and use aConcurrentMergeScheduler. - Switch merge logging on while the writer is open. If the switch lands inside one
updateMergeThreadscall (it runs insetForceMergeMBPerSec,enableAutoIOThrottle,disableAutoIOThrottleand at every merge start and finish), you expect the call to finish and logging to start, as with a constantInfoStreamand as in 4.10. - 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 reprosetForceMergeMBPerSec(10.0)throws after storing 10.0, with assertions on and off. - 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.
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.
setForceMergeMBPerSecstores the value and callsupdateMergeThreads()(ConcurrentMergeScheduler.java:199-202);enableAutoIOThrottle(:214-218) anddisableAutoIOThrottle(:225-228) do the same.merge()calls it right afternewMergeThread.start()(:590-591),runOnMergeFinishedin itsfinallyblock (:691-697), andmerge(CLOSING)at:544-547.- Check 1 (
:354):if (verbose()) { message = new StringBuilder(); ... } else { message = null; }(:363). Logging is off, somessageis null. - Merge logging is switched on now.
- Check 2 (
:391, once per running merge thread):if (verbose())is true, thenmessage.append('\n')(:397): NPE, before that merge'srateLimiter.setMBPerSec(newMBPerSec)(:435); with the switch before the loop, this pass sets no merge's new rate. - Check 3 (
:437, reached even with no merge thread, as in the repro):if (verbose())is true, thenmessage(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
InfoStreamwhoseisEnabled("MS")answer can change: Lucene'sJavaLoggingInfoStream(advertised inMIGRATE.md) or their own logger-backed one, with merge logging switched on while the writer is open. The defaultNO_OUTPUT(always false) andPrintStreamInfoStream(always true) are not affected. - Window. The switch must land inside one
updateMergeThreadscall, a narrow window (its duration was not measured; the method issynchronized, but that only orders the scheduler's own calls, not aLogger.setLevelon another thread); the call runs at every merge start and finish, on close, and in the three setters. A supporting probe with an unmodifiedJavaLoggingInfoStreamand 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
updateMergeThreadscall. - Merge start. The NPE lands in
merge()'scatch (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:AssertionErroratIndexWriter._mergeInit;-da: NPE atIndexWriter.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 throughhandleMergeExceptionafter 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.
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.
/*
* 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();
}
}
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 nullatupdateMergeThreads(ConcurrentMergeScheduler.java:438), with assertions on and off.
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.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT): fails; 5.0.0 to 10.6.0: fail; 4.10.3, 4.10.4: same scenario passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 (nothing platform-specific) |
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
# 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)isEnabledpromises no stable answer, and Lucene shipsJavaLoggingInfoStream, whose answer follows a runtime log-level change.isEnabledwas 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.isEnabledis 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 pairedif (verbose()) { message(...) }idiom (MergeScheduler.java:83-92).IndexWriterConfig.setInfoStream(InfoStream)is public and accepts any non-nullInfoStream(IndexWriterConfig.java:430). - Design history. LUCENE-3598 (#4672, 2011) introduced
isEnabledfor 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
isEnabledoverrides in Lucene's test sources (14 inlucene/core/src/test, for exampleTestConcurrentMergeScheduler.java:801-803, and 1 inlucene/test-framework/src/test) and the 4InfoStreams 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.
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.
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.
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.
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 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.
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).
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive) | 2026-11-03; re-checked the same day | two 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 names | no report of this NPE or of the repeated checks |
| Open and recent PRs | 2026-11-03 | the changed files of all 409 open apache/lucene PRs and of the 42 PRs updated since 2026-11-01 | none changes updateMergeThreads; none of the recent ones touches ConcurrentMergeScheduler.java, MergeScheduler.java or an InfoStream class |
| Mailing lists, downstream trackers, web | 2026-11-03 | 24 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 queries | no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 5adff78bb168 | affected | gradle: fails with assertions on and off (:438); passes with fix A |
| 10.6.0 | affected | fails with assertions on and off (:440) |
| 10.5.2, 10.0.0, 9.12.3 | affected | fail, the same NPE (:439, :439, :431) |
| 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4 | main() form | fail, the same NPE (5.0.0 with assertions on and off) |
| 4.10.3, 4.10.4 | same-scenario check | the same scenario returns normally (logging seen switching on) |
upstream main 1c2072f0284 (2026-11-02) | read | the same lines; not fixed |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | The 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, Low | Own 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), high | Reproduced 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, Low | Steelmanned @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, Low | Re-runs on main fail at :438 (seeds 5A5A1E, 0DDBA11, 5EED0E41, 5EED0D41, A0D17E41); the fix passes. |
| this doc | reproduced; fix A validated | Gradle in a scratch worktree at 5adff78bb168, assertions on and off. |
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 487 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,350 words
- 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,TestMergeSchedulerExternaland the repro pass with fix A, 24 of 24;checkGoogleJavaFormatclean) 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.
- 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-697lucene/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-756lucene/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-1163lucene/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
- TestConcurrentMergeSchedulerVerboseTurnsOn.java (lucene/core, package org.apache.lucene.index, 1 test):
repro/LFV-1210/TestConcurrentMergeSchedulerVerboseTurnsOn.java(55 lines)
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).
assets/LFV-1210/bug.png(487 KB)
