One setter of IndexWriterConfig skips the null check that its siblings have, and the writer only notices during a later merge-on-commit.
IndexWriterConfig.setIndexWriterEventListener(experimental, since 9.0.0) registers a callback thatIndexWritercalls around one event: merging small segments during a commit. The default listener,IndexWriterEventListener.NO_OP_LISTENER, does nothing.- Merge-on-commit (on by default since 9.3.0):
commit()asks the merge policy for merges of the small segments it is about to commit (findFullFlushMerges) and waits up to 500 ms (setMaxFullFlushMergeWaitMillis) for them, so the commit holds fewer, larger segments.
setInfoStream and setMergePolicy reject null at once; the event-listener setter stores it, and the first commit that runs a merge-on-commit merge calls the null listener and throws. Select the still to open it at full size. Generated with AI, checked against the code.- You commit 12 one-document segments, then open a writer with
new IndexWriterConfig().setIndexWriterEventListener(null), everything else default, add 1 document and callcommit(). - You expect the setter to throw
IllegalArgumentException, assetMergePolicy(null)andsetInfoStream(null)do, or elsecommit()to succeed with no listener. - Instead the setter stores the null. With 13 small segments,
commit()asks the defaultTieredMergePolicyfor a merge-on-commit merge (it waits up to 500 ms by default) and gets one. - Before running it, the writer calls the listener without a null check:
NullPointerException: ... because "this.eventListener" is null, far from the setter call that caused it. - That commit writes no new commit point (the last commit keeps 12 documents); the writer stays open, and a retried
commit(), orclose()instead of the retry, commits all 13. With few segmentscommit()works, so early tests pass.
Only code that passes null to this experimental setter, for example a framework forwarding an optional listener that was never set. Everything else can be default: merge-on-commit and TieredMergePolicy are defaults since 9.3.0. The failure is loud and recoverable, with no data lost in the tested setup. A code search found no caller in Elasticsearch, OpenSearch or Solr.
Mechanism, terms and numbers572 words
IndexWriterConfig rejects a null it cannot use: setMergePolicy(null), setMergeScheduler(null), setCodec(null), setInfoStream(null) and the other setters listed below throw IllegalArgumentException at once, the class's policy since 4.3.0 (LUCENE-4833). setIndexWriterEventListener(null), added in 9.0.0, has no such check and stores the null. IndexWriter reads the listener once and calls it without a guard, relying on the non-null default IndexWriterEventListener.NO_OP_LISTENER ("A no-op listener that helps to save null checks"). The call happens only when a commit (commit(), prepareCommit() or the final commit of close()) runs a merge-on-commit merge, which is on by default since 9.3.0. So with an otherwise default config the first commit that finds such a merge throws NullPointerException ... because "this.eventListener" is null, far from the setter call. In the tested default setup nothing is lost: that commit writes no new commit point, the writer stays open, and a retried commit(), or close() instead of the retry, commits every document.
- The setter stores null.
IndexWriterConfig.java:572-577: javadoc "Set event listener to record key events in IndexWriter", bodythis.eventListener = eventListener;, no check. This is the fix site. - Everything else assumes non-null. The default is
NO_OP_LISTENER(LiveIndexWriterConfig.java:149), whose javadoc says it exists "to save null checks" (IndexWriterEventListener.java:25); the interface is@lucene.experimental(:22). - The writer caches it once from the config (
IndexWriter.java:951, final field:406). - Only a commit's merge-on-commit step calls it.
commit()(:4214) leads toprepareCommitInternal(:4252). It readsmaxCommitMergeWaitMillisfrom the config (:3799; default 500,IndexWriterConfig.java:108); when that is above 0 it asks the merge policy for full-flush merges (:3858-3864). If one is found, the writer callseventListener.beginMergeOnFullFlush(:3890, the NPE) and laterendMergeOnFullFlush(:3898), both unguarded. These are the only two call sites.prepareCommit()(:3737) and the final commit ofclose()(:1365, thenshutdown()at:1327) take the same path;getReaderalso runs full-flush merges with the same wait (:535-694) but never calls the listener. - When a merge is found. The default
TieredMergePolicyreturns one once there are enough small segments: in the repro all 13 one-document segments are merged as one. On a fresh index with one document per commit, commits 1 to 8 succeed and commit 9 throws. With the wait set to 0, or a policy that returns no full-flush merge, the null is never touched. - Outcome. The NPE comes before the new commit point is written (
startCommit,:3914), so that commit writes no new commit point: the 13th document is flushed into a new segment, but the last commit still holds 12 documents, and the writer is not closed. In this single-threaded default setup it is recoverable: a retriedcommit()commits all 13 documents, and so doesclose()with the defaultcommitOnClose, which flushes and waits for merges before it commits (:1312-1338), both right after the failed commit (the fact check, 5 of 5 with assertions on and off) and in place of it (the verifiers). Two limits, not claimed as impact: with a merge policy that still returns a full-flush merge whenclose()commits,close()throws the same NPE and the writer rolls back (executed); and the failed commit keeps references on its files, a separate commit-bookkeeping defect for any failure beforestartCommit(LFV-456).
Exposure and likelihood184 words
- Who. Code that passes null to
setIndexWriterEventListener, for example a framework that forwards an optional listener that was never set. A GitHub code search found no caller of the setter in Elasticsearch, OpenSearch or Solr, and no public report of it was found since 9.0.0 (2021), so the likelihood is rare. - Default vs opt-in. The null argument is the only non-default; merge-on-commit (500 ms wait) and
TieredMergePolicyare the defaults since 9.3.0. In 9.0.0 to 9.2.0 the wait defaulted to 0, so only a writer that switched merge-on-commit on reaches it. - Effect. A misleading
NullPointerExceptionfrom a commit, at a moment that depends on how many small segments exist, instead of anIllegalArgumentExceptionat the setter. Because early commits on a small index pass, the mistake can survive an application's own tests. - Not data loss. In the executed single-threaded default setup the throwing commit writes no new commit point and the writer stays open; a retried
commit(), orclose()instead of the retry, commits every document. - Scale. 12 one-document segments plus 1 document; one test thread; it failed on every recorded seed.
One JUnit test method on the public API: commit 12 one-document segments, open a writer with setIndexWriterEventListener(null) and an otherwise default config, add 1 document, call commit(). An IllegalArgumentException from the setter counts as a pass, so it doubles as the regression test. One test thread; it failed on every recorded seed.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.index;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestNullIndexWriterEventListener extends LuceneTestCase {
public void testCommitWithNullEventListener() throws Exception {
IndexWriterConfig config;
try {
config = new IndexWriterConfig().setIndexWriterEventListener(null);
} catch (IllegalArgumentException expected) {
return; // the setter rejects null, like the other IndexWriterConfig setters
}
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w =
new IndexWriter(dir, new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE))) {
for (int i = 0; i < 12; i++) {
w.addDocument(new Document());
w.commit();
}
}
try (IndexWriter w = new IndexWriter(dir, config)) {
w.addDocument(new Document());
// expected: commit() succeeds; actual: NullPointerException from
// IndexWriter.prepareCommitInternal, because commit() runs a merge-on-commit merge
w.commit();
}
}
}
}
cp $REPRO/TestNullIndexWriterEventListener.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestNullIndexWriterEventListener -Ptests.seed=DEADBEEF
# add -Ptests.asserts=false for production mode: the same NullPointerException
- Expected
- The setter throws
IllegalArgumentException, orcommit()succeeds. - Observed
commit()throwsNullPointerException: ... because "this.eventListener" is nullatIndexWriter.prepareCommitInternal(IndexWriter.java:3890), assertions on and off.
More files, environment, full output224 words
TestNullIndexWriterEventListener (one method, extends LuceneTestCase, package org.apache.lucene.index of the lucene/core tests, Apache license header). It is the hunt's minimal test plus a guard: an IllegalArgumentException from the setter counts as a pass, so the same test fits either fix. It passes checkGoogleJavaFormat. The test method draws no random values (it uses ByteBuffersDirectory and new IndexWriterConfig(), not the randomized helpers), though the test framework still picks locale, time zone, default codec and similarity from the seed; it was run with seed DEADBEEF, and the hunt's version failed 5 of 5 with --iters 5 and with other seeds. It fails the same way with assertions on and off.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT): fails; 9.3.0, 9.12.3, 10.5.2, 10.6.0: fail; 9.0.0, 9.2.0: pass with the default config; 8.x: no API |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (only this platform was run) |
cp $REPRO/TestNullIndexWriterEventListener.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestNullIndexWriterEventListener -Ptests.seed=DEADBEEF
# gradle, scratch worktree at 5adff78bb168, -Ptests.seed=DEADBEEF (assertions on; -Ptests.asserts=false gives the same)
TestNullIndexWriterEventListener > testCommitWithNullEventListener FAILED
java.lang.NullPointerException: Cannot invoke "org.apache.lucene.index.IndexWriterEventListener.beginMergeOnFullFlush(org.apache.lucene.index.MergePolicy$MergeSpecification)" because "this.eventListener" is null
at __randomizedtesting.SeedInfo.seed([DEADBEEF:8DAD77C0050D014D]:0)
at org.apache.lucene.index.IndexWriter.prepareCommitInternal(IndexWriter.java:3890)
at org.apache.lucene.index.IndexWriter.commitInternal(IndexWriter.java:4252)
at org.apache.lucene.index.IndexWriter.commit(IndexWriter.java:4214)
at org.apache.lucene.index.TestNullIndexWriterEventListener.testCommitWithNullEventListener(TestNullIndexWriterEventListener.java:45)
1 test completed, 1 failed
# with fix A or fix B: 1 test, 0 failures (assertions off); the repro plus 3 suites: 44 tests, 0 failures, 5 skipped- Since 4.3.0 the class's rule is: "IndexWriterConfig setters now throw an exception when passed null if null is not a valid value" (LUCENE-4833).
- The sibling setters that cannot use null follow it;
setInfoStream, which also has a no-op default, rejects null and points toInfoStream.NO_OUTPUT. IndexWriterrelies on a non-null listener: the defaultNO_OP_LISTENERexists "to save null checks".
The full argument274 words
- The class's recorded null policy.
lucene/CHANGES.txt:13958-13961(4.3.0, LUCENE-4833,9dbfa22b82d, Adrien Grand): "IndexWriterConfig setters now throw an exception when passed null if null is not a valid value." - The sibling setters follow it with
IllegalArgumentException:setOpenMode(IndexWriterConfig.java:160-162),setIndexDeletionPolicy(:221-223),setSimilarity(:258-260),setMergeScheduler(:279-281),setCodec(:297-299),setFlushPolicy(:367, package-private),setInfoStream(:430-436, "To disable logging use InfoStream.NO_OUTPUT"),setInfoStream(PrintStream)(:441-443) andsetMergePolicy(LiveIndexWriterConfig.java:258-259). Not every setter is uniform:setIndexSort(null)also fails at the setter, but with aNullPointerException(IndexWriterConfig.java:505-506); and setters whose null is a valid value accept it: documented forsetIndexCommit(:235),setSoftDeletesField(:561),setParentField(optional,:580) andsetLeafSorter(its getter,LiveIndexWriterConfig.java:423), and forsetMergedSegmentWarmerthe default thatIndexWriterchecks for (LiveIndexWriterConfig.java:131,IndexWriter.java:5785). - The reason behind the policy, as Robert Muir put it on LUCENE-4833 (#5898): "it seems more likely that someone would pass null due to a program mistake, rather than wanting the defaults"; and Adrien Grand: "if someone wants to use the default value, all he has to do is to never call the setter". The policy was kept when it broke a caller (LUCENE-5223, #6287, 2013: the caller was fixed and
setInfoStream's check stayed). - Tests.
TestIndexWriterConfig.testInvalidValues(lucene/core/src/test/org/apache/lucene/index/TestIndexWriterConfig.java:196-274) checks the null rejection of the deletion policy, merge scheduler, similarity and merge policy; nothing tests the listener setter with null. - History. The setter came with LUCENE-9406 (
6ba9fe5be35, lucene-solr#2342, issue #10446, 2021) together with the sentinel and the unguarded calls; none of the comments on #10446 or #2342 discusses null.
The devil's-advocate check164 words
The strongest counter-argument (the cross-model refute review, verdict not real, high): "Null is not a documented listener value. The experimental listener API supplies NO_OP_LISTENER specifically for callers that want no behavior ... adding an eager null check would improve diagnostics but would not demonstrate incorrect behavior for supported inputs."
Why it does not settle it: the writer cannot run with a null listener (it dereferences it, and NO_OP_LISTENER exists to save null checks), so null is not a valid value here, and the class's recorded policy (LUCENE-4833) is to reject such a null at the setter, as the siblings listed above do; this setter does not. The same reviewer called a fail-fast check "reasonable hardening and consistency work". So it is a consistency and diagnostics fix, not a broken documented contract: the setter's javadoc says nothing about null. The argument does hold for impact: the input is a caller mistake on an experimental API and the failure is loud and recoverable, hence priority Low.
Recommended Reject null with IllegalArgumentException, like setInfoStream (fix A)
Make the setter reject null like its siblings: if (eventListener == null) throw new IllegalArgumentException("eventListener must not be null. To record no events use IndexWriterEventListener.NO_OP_LISTENER"), plus a javadoc line. The repro passes; with it, 3 closest suites run 44 tests with 0 failures. Mapping null to NO_OP_LISTENER also passes but hides the caller's mistake.
/**
* Set event listener to record key events in IndexWriter. Must not be null, but {@link
* IndexWriterEventListener#NO_OP_LISTENER} may be used to record nothing.
*/
public IndexWriterConfig setIndexWriterEventListener(
final IndexWriterEventListener eventListener) {
if (eventListener == null) {
throw new IllegalArgumentException(
"eventListener must not be null. "
+ "To record no events use IndexWriterEventListener.NO_OP_LISTENER");
}
this.eventListener = eventListener;
return this;
}All options and trade-offs3 options ยท 417 words
Fixes A and B were built and checked through gradle on 5adff78bb168 in a scratch worktree: the repro with assertions off, then the repro with TestIndexWriterConfig, TestIndexWriterMergePolicy (the merge-on-commit and listener tests) and TestIndexWriterCommit, seed DEADBEEF. The call-site guard option was not run. The regression test for A or B is the one repro test, unchanged.
Reject null with IllegalArgumentException, like setInfoStream (fix A) (validated by the checks) (recommended)
In IndexWriterConfig.setIndexWriterEventListener (IndexWriterConfig.java:572-577) throw IllegalArgumentException for null and point to the no-op sentinel, as setInfoStream does with InfoStream.NO_OUTPUT (+9/-1 including the javadoc).
/**
* Set event listener to record key events in IndexWriter. Must not be null, but {@link
* IndexWriterEventListener#NO_OP_LISTENER} may be used to record nothing.
*/
public IndexWriterConfig setIndexWriterEventListener(
final IndexWriterEventListener eventListener) {
if (eventListener == null) {
throw new IllegalArgumentException(
"eventListener must not be null. "
+ "To record no events use IndexWriterEventListener.NO_OP_LISTENER");
}
this.eventListener = eventListener;
return this;
}Trade-offs
Follows the LUCENE-4833 policy as setInfoStream does (an IllegalArgumentException that points to the no-op sentinel), and reports the mistake at the line that made it. Code that passes null today and never reaches a merge-on-commit (wait 0, or few segments) starts failing at configuration time: a behaviour change, but on an experimental API and for an input the class's policy calls invalid.
What the checks validated
The repro passes with assertions off (the setter now throws and the test's guard returns); the repro plus the 3 suites: 44 tests, 0 failures, 5 skipped; :lucene:core:checkGoogleJavaFormat passes with the fix and the test in place.
Store the sentinel when the argument is null (+2/-1).
this.eventListener =
eventListener == null ? IndexWriterEventListener.NO_OP_LISTENER : eventListener;Trade-offs
No new exception, fully compatible. But LUCENE-4833 explicitly turned away null-means-default for this class (Adrien Grand: "all he has to do is to never call the setter"), and it hides a caller's mistake.
What the checks validated
The repro passes with assertions off (commit() now succeeds); the repro plus the 3 suites: 44 tests, 0 failures, 5 skipped. A separate re-run (the verifier's test) and the minimal repro's recorded runs also pass with this change shadowed in.
Check eventListener != null before beginMergeOnFullFlush and endMergeOnFullFlush (IndexWriter.java:3890, :3898).
Trade-offs
Spreads the null checks the sentinel exists to avoid, and getIndexWriterEventListener() would still return null to other callers.
What the checks validated
Not run.
Downstream, a framework or product that forwards an optional, unset listener into this setter gets a commit-time NullPointerException instead of a configuration error.
A proof-obligation audit of IndexWriterConfig's contract: its sibling setters reject a null they cannot use, and IndexWriter calls the listener without a null check, so the listener setter must never store null. It does not discharge that obligation; a one-test public-API JUnit then witnessed the NullPointerException at commit.
The method in full122 words
Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).
- The finder listed what
IndexWriterassumes about its configuration: the cached listener is called unguarded atIndexWriter.java:3890and:3898, so every valueIndexWriterConfigcan hold must be non-null. The sibling setters discharge this for their own fields with a null check (the LUCENE-4833 policy);setIndexWriterEventListenerdoes not. - The finder then wrote a public-API JUnit test that fails on main with assertions on and off, and on 10.6.0 and 9.12.3; an independent reproducer turned it into the default-config minimal test.
- Two executed cross-model reviews, a devil's-advocate verifier and an adjudication reproduced it (the adjudication with the reproducer's test, the others with their own) ("Prior reports, history and checks").
No prior report found in apache/lucene issues, PRs and commits (with the JIRA archive), the dev@, java-user@, issues@ and commits@ lists, downstream trackers or the web (searched 2026-11-03). Nearest items: #10446 and lucene-solr#2342, which added the listener; nobody discussed null.
Searches3 searches
No matching public report was found in the documented searches. Related, not reports: #10446 and lucene-solr#2342 (LUCENE-9406, added the listener), #5898 (LUCENE-4833, the class's null policy), #6287 (LUCENE-5223, the policy kept for setInfoStream), #10005 (LUCENE-8962, merge-on-commit), #14837 (open, logging next to the listener calls). Limits: keyword search; GitHub search returns at most 50 items per query; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive) | 2026-11-03, re-checked the same day | three scripted prior-report searches (51 queries each: identifiers and the exception message; symptoms such as an NPE from prepareCommitInternal or merge-on-commit; history such as LUCENE-9406, lucene-solr#2342 and LUCENE-4833), earlier runs of 51 and 46 queries, 22 manual issue searches and 4 commit searches | no report; #10446, lucene-solr#2342, #5898 and #6287 are the nearest items |
| Open PRs | 2026-11-03 | the changed files of all 409 open apache/lucene PRs; the 28 updated since 2026-11-01 re-checked | 5 touch IndexWriterConfig.java or LiveIndexWriterConfig.java and 18 touch IndexWriter.java; none edits the setter or the listener calls |
| Downstream code, mailing lists, web | 2026-11-03 | GitHub code search for callers of the setter in Elasticsearch, OpenSearch and Solr; dev@, java-user@, issues@ and commits@; 7 web searches | no caller of the setter downstream; no report |
Since the listener was added (6ba9fe5be35, LUCENE-9406, 2021; first release 9.0.0); reached with the default config since merge-on-commit was turned on by default (b5795db0cf5, LUCENE-10078, 2022; 9.3.0). Every release run from 9.3.0 to 10.6.0 fails. Not fixed upstream.
Versions tested4 rows
Introduced by 6ba9fe5be35 "LUCENE-9406: Add IndexWriterEventListener to track events in IndexWriter (#2342)" (zacharymorn, 2021-03-02; issue #10446). It added the setter without a null check, the NO_OP_LISTENER sentinel and the two unguarded calls; the setter body never changed since. First release 9.0.0.
Default-config reach: b5795db0cf5 "LUCENE-10078: Enable merge-on-refresh by default. (#921)" (Adrien Grand, 2022-06-07; 7d9de759f24 on branch_9x) set the default full-flush merge wait (one setting for commit and getReader) from 0 to 500 ms and added findFullFlushMerges to the stock merge policies. First release with the default config affected: 9.3.0. Not new in 10.6. Not fixed upstream: upstream main 1c2072f0284 (2026-11-02) still stores the argument unchecked.
| Version or commit | Verdict | Result |
|---|---|---|
main 5adff78bb168 | affected | gradle and the standalone runner: fails with assertions on and off; passes with fix A or fix B |
| 9.3.0, 9.12.3, 10.5.2, 10.6.0 | affected | the verifier's default-config test (-da; 10.5.2 from the verifier's runs) and, in the fact check, this doc's own test: the same NullPointerException |
| 9.0.0, 9.2.0 | opt-in only | the default-config test passes (default wait 0; 9.2.0 also with this doc's own test); a probe with merge-on-commit switched on throws the same NullPointerException |
| 8.11.4 | not affected | the API does not exist (compile error) |
A finder, an independent reproducer, two executed cross-model reviews (OpenAI GPT-5.6, refute and intended lenses), a devil's-advocate verifier, an adjudication, a separate re-run and the audit re-run reproduced it and agree on the root cause; a final repro check confirmed it as real but minor, and the refute lens calls it misuse. This doc's gradle runs reproduced it and validated both fixes.
Checks and verdicts308 words
The cross-model reviews ran on main ea57360912ed; the intended-lens tests were re-run on 5adff78bb168 with the same results. All other main runs used 5adff78bb168; the release runs used each release's own jars.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | Own test (a full-flush merge policy, SerialMergeScheduler) fails on main (assertions on and off), 10.6.0 and 9.12.3. |
| independent reproducer | real | Own default-config test fails on main (5 of 5 iterations), 10.6.0, 10.5.2 and 9.12.3; the control without the setter call passes; a retried commit and close() commit all 13 documents. |
| cross-model review, refute lens (OpenAI GPT-5.6, executed) | not real (high) | Reproduced it, also with a stock LogDocMergePolicy, and agreed on the root cause; argued misuse, since null is not a documented listener value. |
| cross-model review, intended lens (OpenAI GPT-5.6, executed) | real (high) | Own test fails; NO_OP_LISTENER and a disabled wait pass; the intended argument rated moderate and rejected by LUCENE-4833. |
| devil's-advocate verifier | real (medium) | Own default-config test fails on main and 10.6.0; the misuse argument is answered by the class's null policy. |
| adjudication | confirmed, Low | Re-ran the reproducer's test on main (assertions on and off): fails; dropped a by-reading data-loss claim on close(). |
| separate re-run, audit re-run | reproduced; confirmed | The verifier's default-config test fails on main and passes with fix B shadowed in. |
| final repro check | confirmed, Low | Judged the minimal repro's recorded runs (fail on main with assertions on and off, pass with fix B): real but minor. |
| this doc | reproduced; fixes A and B validated | Gradle without and with each fix in a scratch worktree at 5adff78bb168. |
| independent fact checks | real; wording fixed | Re-ran this doc's test on main and the releases, both fixes, and close() right after the failed commit; their wording corrections are applied. |
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 526 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,413 words
- Registry title
- IndexWriterConfig.setIndexWriterEventListener accepts null, unlike the class's other component setters, so a later commit() that runs a merge-on-commit merge (on by default) throws NullPointerException instead of the setter rejecting the null (since 9.0.0; with the default config since 9.3.0)
- Area
- core / index: IndexWriterConfig (setIndexWriterEventListener) and IndexWriter commit (merge-on-commit)
- Priority
- Low, rank 661: The experimental IndexWriterConfig.setIndexWriterEventListener accepts null, and a later commit that runs a merge-on-commit merge (on by default since 9.3.0) throws NullPointerException far from the setter. It needs that null argument, and the failure is loud and recoverable: in the tested default setup a retried commit succeeds and nothing is lost.
- Impact class
- minor
- Impact note
- Only applications that pass null to the @lucene.experimental IndexWriterConfig.setIndexWriterEventListener (for example code that forwards an optional listener that is not set) are hit: the setter accepts it, and the next commit() or prepareCommit() that selects a merge-on-commit merge (default 500 ms wait; with the default TieredMergePolicy once a dozen small segments exist) throws NullPointerException instead of the setter throwing IllegalArgumentException, with -ea and -da and no scale; in the executed default setup nothing is lost (the writer stays open, a retried commit() succeeds and close() commits every document); every release from 9.3.0 through 10.6.0 and main with the default config, and 9.0.0-9.2.0 only with merge-on-commit switched on.
- Summary
- You pass null to IndexWriterConfig.setIndexWriterEventListener, for example because your code forwards an optional listener that is not set. You expect what IndexWriterConfig does with every other null it does not support (since LUCENE-4833 its setters throw IllegalArgumentException, and setInfoStream points to its no-op sentinel), or else a writer that simply has no listener. Instead the setter stores the null, IndexWriter keeps it, and a later commit() that runs a merge-on-commit merge, which is on by default, throws NullPointerException 'because "this.eventListener" is null', far from the call that caused it.
- Introduced
- 6ba9fe5be35dea3ff66d89f63bc876286f186ea4: LUCENE-9406 'Add IndexWriterEventListener to track events in IndexWriter' (apache/lucene-solr#2342, issue apache/lucene#10446), by zacharymorn. It added the setter without a null check, the NO_OP_LISTENER default and the two unguarded listener calls in IndexWriter.prepareCommitInternal [read: git blame of IndexWriterConfig.java:572-577 on 5adff78bb168 and git log -S in the history clone; the setter body never changed since; exec: 9.0.0 fails with merge-on-commit switched on]. The default-config reach came with b5795db0cf5 (LUCENE-10078, apache/lucene#921, Adrien Grand, 2022-06-07; branch_9x 7d9de759f24), which turned merge-on-commit on by default (DEFAULT_MAX_FULL_FLUSH_MERGE_WAIT_MILLIS 0 to 500) [exec: the one-test repro passes on 9.2.0 and fails on 9.3.0]. (2021-03-02 (author date of 6ba9fe5be35, commit date the same day; first release 9.0.0); 2022-06-07 (b5795db0cf5, the default-config reach; first release 9.3.0))
- First affected version
- 9.0.0 [exec: with merge-on-commit switched on (a positive maxFullFlushMergeWaitMillis and a merge policy that returns full-flush merges) 9.0.0 and 9.2.0 throw the same NullPointerException, while the default-config repro passes there because the default wait was 0]; with the default config 9.3.0 [exec]. 8.x is not affected [exec: on 8.11.4 the repro does not compile, the API does not exist].
- Affected versions
- Every release from 9.0.0 through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT): 9.0.0-9.2.0 only with merge-on-commit switched on explicitly, 9.3.0 and later with the default config. [exec, JDK 25.0.3, Linux aarch64] the one-test repro TestNullEventListenerDefaultConfigCommit fails on main with -ea and -da (the independent reproduction, adjudication, history step, re-run of 2026-11-03), on 10.6.0, 10.5.2 and 9.12.3 (the independent reproduction, -da), and on 10.6.0, 9.12.3 and 9.3.0 (history step, -da); it passes on 9.2.0 and 9.0.0 (-da), where the history step's opt-in probe fails with the same NullPointerException; the intended-behaviour check's own test fails on main (-ea, -da) and 10.6.0 (-da). [read] the setter, the cached field and the unguarded calls are unchanged in every release since 9.0.0. Not affected: 8.x and older (API absent; 8.11.4 executed). Not new in 10.6 (10.5.2 and 9.12.3 fail too). Not fixed upstream: upstream main 1c2072f0284 (2026-11-02) still stores the argument unchecked (IndexWriterConfig.java:573-577, contents API, 2026-11-03).
- 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 independent reproduction's and the intended-behaviour check's searches; rechecked the same day): three scripted prior-report searches of 51 queries each (identifiers and the exception message; symptoms such as a NullPointerException from prepareCommitInternal or merge-on-commit and 'setter accepts null'; history such as LUCENE-9406, lucene-solr#2342 and LUCENE-4833) over apache/lucene issues and PRs, commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive, plus the finder's run and the intended-behaviour check's 46-query run; 22 manual GitHub issue searches and 4 commit searches; GitHub code search for callers of the setter (none in Elasticsearch, OpenSearch or Solr); the changed files of all 409 open apache/lucene PRs (5 touch IndexWriterConfig.java or LiveIndexWriterConfig.java and 18 touch IndexWriter.java, none edits the setter or the listener calls); 7 web searches; every comment of #10446 and lucene-solr#2342. The re-check (10 searches, the changed files of the 28 open PRs updated since 2026-11-01, upstream commits by author date and the setter on upstream main) found nothing new: the three open PRs among them that touch IndexWriter.java (#16923, #16571, #16847) change no listener line, and upstream main 1c2072f0284 still stores the argument unchecked. Related, not reports: #10446 (LUCENE-9406) and lucene-solr#2342 (added the listener; nobody discussed null), #5898 (LUCENE-4833, the class's null policy, 2013), #6287 (LUCENE-5223, a null InfoStream rejected and the check kept), #10005 (LUCENE-8962, merge-on-commit), #3930 (LUCENE-2856, an earlier listener proposal), #14837 (open, InfoStream logging next to the listener calls).
- Lucene commit tested
- main
5adff78bb168(2026-11-02) through gradle on 2026-11-03 in a scratch worktree: the repro with assertions on and off without a fix (fails), and with each of fix A and fix B (passes), plusTestIndexWriterConfig,TestIndexWriterMergePolicyandTestIndexWriterCommit(44 tests, 0 failures, for each fix) andcheckGoogleJavaFormatfor fix A; the same repro through a standalone runner on the main jars (-ea,-da: fails). The hunt ran the verifier's equivalent default-config test on 8.11.4 (API absent), 9.0.0, 9.2.0 (pass; an opt-in probe fails), 9.3.0, 9.12.3, 10.5.2 and 10.6.0 (fail); the fact check re-ran this doc's own test on 9.2.0 (pass), 9.3.0, 9.12.3, 10.5.2 and 10.6.0 (fail), and ranclose()right after the failed commit (all 13 documents committed, 5 of 5, assertions on and off). Only Linux aarch64 with OpenJDK 25.0.3 was run.
- Finding record
lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:572-577 (at 5adff78bb168) (setIndexWriterEventListener: javadoc 'Set event listener to record key events in IndexWriter', body 'this.eventListener = eventListener', no null check: the fix site), :161-162, :222-223, :259-260, :280-281, :298-299, :366-367 (setOpenMode, setIndexDeletionPolicy, setSimilarity, setMergeScheduler, setCodec, setFlushPolicy throw IllegalArgumentException '... must not be null'), :427-436 (setInfoStream: 'Must not be null, but InfoStream#NO_OUTPUT may be used to suppress output'; throws with 'To disable logging use InfoStream.NO_OUTPUT'), :108 (DEFAULT_MAX_FULL_FLUSH_MERGE_WAIT_MILLIS = 500)lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:149 (at 5adff78bb168) (eventListener = IndexWriterEventListener.NO_OP_LISTENER by default), :258-259 (setMergePolicy rejects null), :477 (getIndexWriterEventListener)lucene/core/src/java/org/apache/lucene/index/IndexWriterEventListener.java:22 (at 5adff78bb168) (@lucene.experimental), :25-33 (NO_OP_LISTENER: 'A no-op listener that helps to save null checks')lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:406 (at 5adff78bb168) and :951 (the listener is cached once from the config), :3857-3864 (merge-on-commit selected when maxCommitMergeWaitMillis > 0), :3886-3898 (eventListener.beginMergeOnFullFlush and endMergeOnFullFlush, unguarded), :3737 and :4252 (prepareCommit and commitInternal call prepareCommitInternal), :1311-1338 (close: flush and waitForMerges before commitInternal)lucene/CHANGES.txt:13958-13961 (at 5adff78bb168) (4.3.0, LUCENE-4833: 'IndexWriterConfig setters now throw an exception when passed null if null is not a valid value.')
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:108, (at 5adff78bb168) :572-577lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:149, (at 5adff78bb168) :477lucene/core/src/java/org/apache/lucene/index/IndexWriterEventListener.java:22-33 (at 5adff78bb168)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:406, (at 5adff78bb168) :535-694, :951, :1312-1338, :1365, :3737, :3799, :3858-3864, :3890, :3898, :3914, :4214, :4252
- Why it looks unintended
lucene/CHANGES.txt:13958-13961 (at 5adff78bb168)lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:160-162, (at 5adff78bb168) :221-223, :258-260, :279-281, :297-299, :367, :430-436, :441-443, :505-506, :235, :561, :580, :572-577lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:131, (at 5adff78bb168) :258-259, :423; IndexWriter.java:5785; IndexWriterEventListener.java:22-33lucene/core/src/test/org/apache/lucene/index/TestIndexWriterConfig.java:196-274 (at 5adff78bb168)
- Suggested fix
lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:430-436, (at 5adff78bb168) :572-577; IndexWriter.java:3890, :3898
- Diagrams
lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:430-436, (at 5adff78bb168) :572-577; LiveIndexWriterConfig.java:258-259; IndexWriter.java:951, :3858-3898
- TestNullIndexWriterEventListener.java (lucene/core, package org.apache.lucene.index, 1 test):
repro/LFV-1188/TestNullIndexWriterEventListener.java(49 lines)
The diagram follows the repro's setup on main 5adff78bb168 (IndexWriterConfig.java:572-577, IndexWriter.java:951, :3858-3898) and the diagram probe's executed values.
assets/LFV-1188/bug.png(526 KB)
