All findings · Lucene finding · LFV-1188 · indexwriterconfig-accepts-null-event-listener

IndexWriterConfig accepts a null event listener, and a later commit() throws NullPointerException

IndexWriterConfig.setIndexWriterEventListener(null) is accepted, although its sibling setters reject a null they cannot use, and the first commit() that runs a merge-on-commit merge (on by default since 9.3.0) then throws NullPointerException. Nothing is lost, and a retried commit works.

Summary

Priority
Low, rank 661The 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
minor
Since
9.0.0 (2021, LUCENE-9406); with the default config since 9.3.0introduced 2021-03-02 · about 5 years
Affected
every release from 9.0.0 to 10.6.0 and main (9.0.0 to 9.2.0 only with merge-on-commit switched on); not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of IndexWriterConfig's contract, 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

One setter of IndexWriterConfig skips the null check that its siblings have, and the writer only notices during a later merge-on-commit.

Background: the event listener and merge-on-commit

  1. IndexWriterConfig.setIndexWriterEventListener (experimental, since 9.0.0) registers a callback that IndexWriter calls around one event: merging small segments during a commit. The default listener, IndexWriterEventListener.NO_OP_LISTENER, does nothing.
  2. 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.
Diagram of the bug: setMergePolicy(null) and setInfoStream(null) throw IllegalArgumentException, while setIndexWriterEventListener(null) returns normally and the null is stored. IndexWriter copies the null listener. After 12 committed one-document segments and 1 new document, commit() finds a merge-on-commit merge of the 13 segments and calls beginMergeOnFullFlush on the null listener: NullPointerException at IndexWriter.prepareCommitInternal. The last commit keeps 12 documents; a retried commit() commits 13.
Sibling setters such as 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.

The finding, step by step

  1. You commit 12 one-document segments, then open a writer with new IndexWriterConfig().setIndexWriterEventListener(null), everything else default, add 1 document and call commit().
  2. You expect the setter to throw IllegalArgumentException, as setMergePolicy(null) and setInfoStream(null) do, or else commit() to succeed with no listener.
  3. Instead the setter stores the null. With 13 small segments, commit() asks the default TieredMergePolicy for a merge-on-commit merge (it waits up to 500 ms by default) and gets one.
  4. 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.
  5. That commit writes no new commit point (the last commit keeps 12 documents); the writer stays open, and a retried commit(), or close() instead of the retry, commits all 13. With few segments commit() works, so early tests pass.

Who is affected

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 mechanism (main 5adff78bb168, under lucene/core/src/java/org/apache/lucene/index/)

  1. The setter stores null. IndexWriterConfig.java:572-577: javadoc "Set event listener to record key events in IndexWriter", body this.eventListener = eventListener;, no check. This is the fix site.
  2. 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).
  3. The writer caches it once from the config (IndexWriter.java:951, final field :406).
  4. Only a commit's merge-on-commit step calls it. commit() (:4214) leads to prepareCommitInternal (:4252). It reads maxCommitMergeWaitMillis from 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 calls eventListener.beginMergeOnFullFlush (:3890, the NPE) and later endMergeOnFullFlush (:3898), both unguarded. These are the only two call sites. prepareCommit() (:3737) and the final commit of close() (:1365, then shutdown() at :1327) take the same path; getReader also runs full-flush merges with the same wait (:535-694) but never calls the listener.
  5. When a merge is found. The default TieredMergePolicy returns 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.
  6. 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 retried commit() commits all 13 documents, and so does close() with the default commitOnClose, 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 when close() 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 before startCommit (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 TieredMergePolicy are 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 NullPointerException from a commit, at a moment that depends on how many small segments exist, instead of an IllegalArgumentException at 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(), or close() instead of the retry, commits every document.
  • Scale. 12 one-document segments plus 1 document; one test thread; it failed on every recorded seed.

Minimal repro

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.

TestNullIndexWriterEventListener.java (lucene/core, package org.apache.lucene.index, 1 test)49 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.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();
      }
    }
  }
}
Command
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, or commit() succeeds.
Observed
commit() throws NullPointerException: ... because "this.eventListener" is null at IndexWriter.prepareCommitInternal(IndexWriter.java:3890), assertions on and off.

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

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.

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (only this platform was run)
Command (environment)
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

Observed output

# 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

Why it looks unintended

  • 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 to InfoStream.NO_OUTPUT.
  • IndexWriter relies on a non-null listener: the default NO_OP_LISTENER exists "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) and setMergePolicy (LiveIndexWriterConfig.java:258-259). Not every setter is uniform: setIndexSort(null) also fails at the setter, but with a NullPointerException (IndexWriterConfig.java:505-506); and setters whose null is a valid value accept it: documented for setIndexCommit (:235), setSoftDeletesField (:561), setParentField (optional, :580) and setLeafSorter (its getter, LiveIndexWriterConfig.java:423), and for setMergedSegmentWarmer the default that IndexWriter checks 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.

Suggested fix

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.

Treat null as NO_OP_LISTENER (fix B) (validated by the checks)

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.

Null checks at the two call sites in IndexWriter (not recommended) (not run)

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 note

Downstream, a framework or product that forwards an optional, unset listener into this setter gets a commit-time NullPointerException instead of a configuration error.

How it was found

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 IndexWriter assumes about its configuration: the cached listener is called unguarded at IndexWriter.java:3890 and :3898, so every value IndexWriterConfig can hold must be non-null. The sibling setters discharge this for their own fields with a null check (the LUCENE-4833 policy); setIndexWriterEventListener does 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").

Prior reports, history and checks

Prior reports

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.

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 daythree 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 searchesno report; #10446, lucene-solr#2342, #5898 and #6287 are the nearest items
Open PRs2026-11-03the changed files of all 409 open apache/lucene PRs; the 28 updated since 2026-11-01 re-checked5 touch IndexWriterConfig.java or LiveIndexWriterConfig.java and 18 touch IndexWriter.java; none edits the setter or the listener calls
Downstream code, mailing lists, web2026-11-03GitHub code search for callers of the setter in Elasticsearch, OpenSearch and Solr; dev@, java-user@, issues@ and commits@; 7 web searchesno caller of the setter downstream; no report

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle 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 affectedthe 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 onlythe 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 affectedthe API does not exist (compile error)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedOwn test (a full-flush merge policy, SerialMergeScheduler) fails on main (assertions on and off), 10.6.0 and 9.12.3.
independent reproducer realOwn 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, LowRe-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; confirmedThe verifier's default-config test fails on main and passes with fix B shadowed in.
final repro check confirmed, LowJudged 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 validatedGradle without and with each fix in a scratch worktree at 5adff78bb168.
independent fact checks real; wording fixedRe-ran this doc's test on main and the releases, both fixes, and close() right after the failed commit; their wording corrections are applied.

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

Finding record

LFV-1188, snapshot of 2026-11-06

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), plus TestIndexWriterConfig, TestIndexWriterMergePolicy and TestIndexWriterCommit (44 tests, 0 failures, for each fix) and checkGoogleJavaFormat for 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 ran close() 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.

Lucene and upstream sources

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-577
  • lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:149, (at 5adff78bb168) :477
  • lucene/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-577
  • lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:131, (at 5adff78bb168) :258-259, :423; IndexWriter.java:5785; IndexWriterEventListener.java:22-33
  • lucene/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

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

Repro files

About the diagrams

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.

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.