All findings · Lucene finding · LFV-013 · merge-npe-unfinished-dv-packet

Concurrent doc-values updates can make a merge throw NullPointerException and close the IndexWriter

A merge that starts while another thread applies a doc-values update of a field the merging segments lack, when the segment holds a pending update of another doc-values field, throws NullPointerException and closes the IndexWriter, losing every uncommitted change. Rare, but every tested release since 7.0.0 has it.

Summary

Priority
Medium, rank 115A default-config race between doc-values updates on a field new to a segment and a merge throws NPE and tragically closes the writer, losing uncommitted docs, but it is loud and very rare (1 seed in 15,000 stress runs) and soft-delete-only users such as Elasticsearch and OpenSearch are unaffected.
Impact
crash
Since
7.0.0 (LUCENE-7868, 2017)introduced 2017-06-21 · about 9 years
Affected
every release run, 7.0.0 to 10.5.2 (18), and main
Status
documented
Found by
plain multi-threaded stress test with a contract oracle

Upstream status: Fix in progress, by jimczi (PR #16989) on 2026-11-06. PR #16989 references this bug. Source

AI-assisted page: this finding was first signalled by a testing baseline (randomized, fuzz, property-based or stress testing, or static analysis) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. A fix for it is open upstream (see Upstream status). Snapshot of 2026-11-06.

The finding in 30 seconds

Lucene applies a doc-values update in two steps: apply it to each segment, then mark it finished. A merge that starts between the two steps can see a field that has a type but no values.

Diagram of the race between thread 2's doc-values update of field tag and the merge of segments _0 and _1: the merge writes _0's finished update, adds tag to _0's field list without values, and fails with a NullPointerException in DocIDMerger, after which the IndexWriter is closed.
How the race unfolds, from the two doc-values updates to the failed merge and the closed writer. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Commit segments _0 = {a} and _1 = {b}, both with a NUMERIC doc-values field num. Thread 1 sets a.num = 7, adds document c, the only one with a BINARY doc-values field tag, and flushes: c lands in segment _2.
  2. Thread 2 calls updateBinaryDocValue(a, "tag", "a-tag"), adds d and flushes; it applies its update to _0 itself. In the moment before it marks the update finished, thread 1 merges _0 and _1.
  3. You expect the merge to succeed: 4 documents, a.num = 7, a.tag = a-tag. Without the overlap you get exactly that; the controls with the second update on num, or with no first update, also merge cleanly, each with the values its own updates set.
  4. Instead the merge first writes _0's finished update (num = 7) but also lists tag as a BINARY field of _0, with no values behind it. No merging segment has data for tag, and DocIDMerger throws NullPointerException: ... "this.current" is null.
  5. The writer treats it as tragic and closes (isOpen() is false): documents c and d and both updates are lost. The last commit, 2 documents, is intact.

Who is affected

Applications that update two different doc-values fields on one segment (soft deletes count as one) while merges run on another thread, for example a background merge. Default codec; default configuration by code reading. Rare, 0.05-0.2% of runs in a race built to collide; but when it hits, the writer closes and every change since the last commit is lost.

Mechanism, terms and numbers840 words

Lucene applies a doc-values update packet in two steps: it applies the packet to each segment, then marks it finished. A merge that starts between the two steps waits only for older packets. Before merging it writes the segment's finished updates, and in that step one loop of ReadersAndUpdates.writeFieldUpdates also adds the unfinished update's field to the segment's field list, typed BINARY or NUMERIC but with no values (doc-values generation -1). If no segment in the merge has that field, the merge builds a DocIDMerger over an empty list and throws NullPointerException; IndexWriter treats it as tragic and closes, and every change since the last commit is lost. It needs updates of two different doc-values fields on the same segment (a soft delete counts as one), one of them new to every merging segment, and a merge that starts inside the window. It is rare (0.05-0.2% of runs in a two-thread race built to collide) but present in every release tested from 7.0.0 to 10.5.2 (18) and on main; a 12-line fix passes all of :lucene:core:test.

Terms

  • Doc-values update: updateNumericDocValue, updateBinaryDocValue, updateDocValues, or a soft delete (softUpdateDocument / softUpdateDocuments, or updateDocValues on the soft-deletes field). The writer buffers it and later freezes it into a packet with a generation number, delGen.
  • Applying a packet: since 7.0.0 the thread that flushes resolves the packet itself, without holding the writer lock, adds the updates to each segment's in-memory ReadersAndUpdates, and only then marks the packet finished. Updates of finished packets are later written to disk as a new doc-values generation of the segment, together with a new generation of its field list (.fnm).
  • Merge start: before merging, mergeInit waits for the packets the merging segments depend on, then writes their finished updates to disk, so the merged segment sees them.

The bug, in the repro

  1. Thread 2 applies the packet with tag to _0 (FrozenBufferedUpdates.java:355, called from IndexWriter.java:6592 without the writer lock). It marks it finished only later (IndexWriter.java:6640).
  2. In between, thread 1's merge of _0 and _1 starts. waitApplyForMerge waits only for packets with delGen <= the merging segments' highest delete generation (BufferedUpdatesStream.java:188); thread 2's packet is newer, so the merge does not wait.
  3. The merge writes _0's pending updates up to the completed generation (IndexWriter.java:5310, ReaderPool.java:272), which covers thread 1's num update but not thread 2's tag update.
  4. In ReadersAndUpdates.writeFieldUpdates, the "anything to write" check (:872) and handleDVUpdates (:315, "nothing to apply yet" at :322) both skip updates above that generation. The loop "create new fields with the right DV type" (:914-930) does not: it adds tag to _0's field list through constructFieldInfo (:925), typed BINARY with doc-values generation -1 (FieldInfos.java:678). The new field list is written (:944).
  5. A field with generation -1 and no overlay (the overlay branch, SegmentDocValuesProducer.java:77-81, does not apply) is read from the segment's original doc-values file (:83-86), which never had tag, so getBinary returns null (PerFieldDocValuesFormat.java:322-325), although DocValuesProducer promises "The return value is never {@code null}".
  6. The merge drops the null producer (DocValuesConsumer.java:336); _1 has no tag either, so DocIDMerger.of gets an empty list, its current is null (DocIDMerger.java:116), and next() throws (:123). handleMergeException swallows nothing but a MergeAbortedException of a non-external merge (IndexWriter.java:5030-5058), so the NPE reaches the outer catch of IndexWriter.merge, which calls tragicEvent(t, "merge") (:5109-5113), and the writer closes.

With a soft delete as the second update, the soft-deletes field takes the place of tag and the merge fails in the numeric path (DocValuesConsumer.java:218, :234). An NRT reader opened inside the window with DirectoryReader.open(writer, false, false) shows _0 with tag typed BINARY, generation -1 and getBinaryDocValues("tag") == null, and CheckIndex.testDocValues on it throws NullPointerException ("it1" is null). Ordinary reads on that reader: getBinaryDocValues("tag") on _0 returns null and MultiDocValues.getBinaryValues returns only c's value, exactly what the same reader shows with the fix, while the DocValues.getBinary helper throws a misleading IllegalStateException: unexpected docvalues type BINARY for field 'tag' (expected=BINARY) (DocValues.java:214-230,262-268). This symptom needs only tag to be new to _0: with tag also in _1, the merge succeeds, but the NRT reader in the window still fails CheckIndex. With the fix the reader passes CheckIndex.

Found by reading and not run: under doc-values RAM pressure the applying thread itself calls writeSomeDocValuesUpdates() (IndexWriter.java:6600) before it marks its packet finished (:6640); that calls writeFieldUpdates with the completed generation (:854-858), so the same bad field list can be written without any merge (by reading, until the next write after the packet is finished). An NRT reader opened in the window and passed to another writer's addIndexes(CodecReader...) may reach the same null producer.

All line numbers are Lucene main ea57360912ed, lucene/core/src/java/org/apache/lucene/.

Exposure and likelihood479 words
  • Who is hit. Applications that update two or more different doc-values fields (updateNumericDocValue, updateBinaryDocValue, updateDocValues, or soft deletes plus one other updated field) where one field is missing from some segments, while merges run on another thread. The merge can be a background ConcurrentMergeScheduler merge: a no-hook race with CMS hit 39 merge NPEs in 20,000 runs. The stress harness that found it used ordinary operations (adds, updates, deletes, doc-values and soft updates, flush, commit, forceMerge(1), CMS or the serial scheduler).
  • Default or opt-in. The default codec is enough: the JUnit fails under Lucene104, the default codec, and under the randomized test codecs. The default IndexWriterConfig is very likely enough too, but that is from the code, not from a run: every executed repro and race sets a one-shot merge policy, disables doc-count auto-flush and uses a 256 MB RAM buffer (all but the CMS race also use SerialMergeScheduler), only to pin which segments merge, on which thread, and when flushes and doc-values writes happen; none of these settings appears in the code path. The stress harness that found it used a random configuration. Doc-values updates are a regular public API; soft deletes are opt-in (setSoftDeletesField). A custom codec whose producer returns an empty instance instead of null might avoid the NPE, but the segment would still list the field with no values (not run).
  • Not hit. Applications that update only one doc-values field, for example soft deletes alone (that field's finished update gives it a real generation; the study's control with both updates on tag passes), and merges where the updated segment already has the late field (control testControlLateUpdateOfExistingField) or another merging segment has it (the fact check's probe C: the merge succeeds, but an NRT reader opened in the window still fails CheckIndex).
  • Consequence. The writer closes itself; later calls throw AlreadyClosedException; every document and update since the last commit is lost, including flushed but uncommitted segments, so applications without their own transaction log lose acknowledged writes. The last commit stays intact (CheckIndex clean after the tragedy). The merge failure is loud and no committed data is corrupted; an NRT reader opened inside the window shows the broken field (see What happens).
  • Scale and frequency. Any index size (the repro has 4 documents). The window is the time one thread spends between applying a packet and marking it finished. The original stress hunt hit it with 1 seed in 15,000 runs; that seed repeated 3,000 times hit it 18-23 times on main and 64-67 times on 10.5.2, 9.12.3 and 8.11.4; two-thread races with a random 0-100 microsecond merge delay hit it 20 (binary update), 9 (soft delete) and 39 (CMS) times per 20,000 runs on main, and 53 per 20,000 at the introducing commit. These rates come from scenarios built to collide, not from production incidence.

Minimal repro

The test shows that a merge of _0 and _1 that starts while another thread is applying an updateBinaryDocValue of a field that neither segment has (held at one InfoStream message) throws NullPointerException, and the IndexWriter closes.

TestMergeDuringUnfinishedDocValuesUpdate.java (JUnit test)117 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 java.util.concurrent.CountDownLatch;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import org.apache.lucene.document.BinaryDocValuesField;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.InfoStream;

public class TestMergeDuringUnfinishedDocValuesUpdate extends LuceneTestCase {

  public void testMergeWhileUpdateOfFieldNewToSegmentIsApplied() throws Exception {
    CountDownLatch held = new CountDownLatch(1);
    CountDownLatch release = new CountDownLatch(1);
    InfoStream holdLateThread =
        new InfoStream() {
          @Override
          public void message(String component, String message) {
            if (Thread.currentThread().getName().equals("late")
                && message.contains("done inner apply del packet")) {
              held.countDown();
              try {
                release.await(60, TimeUnit.SECONDS);
              } catch (InterruptedException e) {
                throw new RuntimeException(e);
              }
            }
          }

          @Override
          public boolean isEnabled(String component) {
            return component.equals("BD");
          }

          @Override
          public void close() {}
        };
    MergePolicy mergeFirstTwoOnRequest =
        new FilterMergePolicy(NoMergePolicy.INSTANCE) {
          @Override
          public MergeSpecification findMerges(
              MergeTrigger trigger, SegmentInfos infos, MergeContext context) {
            if (trigger != MergeTrigger.EXPLICIT) {
              return null;
            }
            MergeSpecification spec = new MergeSpecification();
            spec.add(new OneMerge(infos.asList().subList(0, 2)));
            return spec;
          }
        };
    IndexWriterConfig iwc =
        new IndexWriterConfig()
            .setCodec(TestUtil.getDefaultCodec())
            .setMergePolicy(mergeFirstTwoOnRequest)
            .setMergeScheduler(new SerialMergeScheduler())
            .setInfoStream(holdLateThread);
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      Document a = new Document();
      a.add(new StringField("id", "a", Field.Store.NO));
      a.add(new NumericDocValuesField("num", 0));
      w.addDocument(a);
      w.flush();
      w.addDocument(new Document());
      w.flush();
      w.updateNumericDocValue(new Term("id", "a"), "num", 7);
      Document c = new Document();
      c.add(new BinaryDocValuesField("tag", new BytesRef("c")));
      w.addDocument(c);
      w.flushNextBuffer();
      FutureTask<Void> lateUpdate =
          new FutureTask<>(
              () -> {
                w.updateBinaryDocValue(new Term("id", "a"), "tag", new BytesRef("a"));
                w.addDocument(new Document());
                w.flushNextBuffer();
                return null;
              });
      Thread late = new Thread(lateUpdate, "late");
      late.start();
      assertTrue(held.await(60, TimeUnit.SECONDS));
      try {
        w.maybeMerge();
      } catch (NullPointerException e) {
        // expected: the merge of _0 and _1 succeeds; actual: NPE in DocIDMerger, the writer closes
        throw new AssertionError("expected the merge to succeed, but it threw " + e, e);
      } finally {
        release.countDown();
        late.join();
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-013/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.index.TestMergeDuringUnfinishedDocValuesUpdate
Expected vs actual
Expected maybeMerge() to succeed; actual: NullPointerException ... "this.current" is null at DocIDMerger$SequentialDocIDMerger.next, with -ea and -da.

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

More files, environment, full output59 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected the merge to succeed, but it threw java.lang.NullPointerException: Cannot invoke "org.apache.lucene.index.DocIDMerger$Sub.nextMappedDoc()" because "this.current" is null
       org.apache.lucene.index.TestMergeDuringUnfinishedDocValuesUpdate.testMergeWhileUpdateOfFieldNewToSegmentIsApplied(TestMergeDuringUnfinishedDocValuesUpdate.java:110)
     Caused by: java.lang.NullPointerException: Cannot invoke "org.apache.lucene.index.DocIDMerger$Sub.nextMappedDoc()" because "this.current" is null
       org.apache.lucene.index.DocIDMerger$SequentialDocIDMerger.next(DocIDMerger.java:123)
       org.apache.lucene.codecs.DocValuesConsumer$4.nextDoc(DocValuesConsumer.java:357)
       org.apache.lucene.codecs.lucene90.Lucene90DocValuesConsumer.addBinaryField(Lucene90DocValuesConsumer.java:637)
       org.apache.lucene.codecs.DocValuesConsumer.mergeBinaryField(DocValuesConsumer.java:303)
       org.apache.lucene.codecs.DocValuesConsumer.merge(DocValuesConsumer.java:146)
       [...]

Why it looks unintended

  • IndexWriter is documented as "completely thread safe"; nothing documents that legal concurrent calls can fail a merge and close it.
  • DocValuesProducer.getBinary: "The return value is never {@code null}"; here it returns null for a field the segment lists as BINARY.
  • The method's two other loops skip not-yet-applicable updates ("nothing to apply yet"); only the field-creation loop does not.
The full argument372 words
  • Thread safety. IndexWriter javadoc: "IndexWriter instances are completely thread safe, meaning multiple threads can call any of its methods, concurrently" (IndexWriter.java:167-168), and the writer closes itself "if you hit an Error, or disaster strikes during a checkpoint" (:162-165). Closing on a failed merge is deliberate too (:5109-5113; getTragicException gives "disk full while flushing a new segment" as an example, :6108-6111), but nothing documents that legal concurrent calls can make a merge fail: this NPE comes from a broken internal invariant, not from the environment.
  • Every call is legal. updateBinaryDocValue: "You can only update fields that already exist in the index, not add new fields through this method. You can only update fields that were indexed only with doc values" (IndexWriter.java:1983-1984); tag exists in _2, doc values only. A field missing from a segment is a handled case ("the field is not present in this segment so we clone the global field (which is guaranteed to exist)", ReadersAndUpdates.java:922-923), and the no-window control passes on that path.
  • The producer contract. DocValuesProducer.java:45,52: "The return value is never {@code null}". In LUCENE-10357 (#11393) jpountz stated the rule: "On doc values and norms, we require that codec APIs return non-null empty instances on fields that have the feature turned on on their FieldInfo". CheckIndex relies on it (CheckIndex.java:3792-3795) and fails on such a reader.
  • The method's own intent. "nothing to apply yet" (ReadersAndUpdates.java:322), "not yet applied" (:973) and the filtered "any" check (:872) all skip updates above the completed generation.
  • History. Before LUCENE-7868 (6.6.x) packets were applied under the writer lock and the loop only saw resolved updates. LUCENE-7868 moved the work off the lock and filtered two of the three loops; LUCENE-7888 (#8939, fixed in 7.0.0) treated a similar merge-versus-update race from the same change (lost updates when a merge completes) as a bug. No javadoc, CHANGES entry or test says a merge may fail this way.
  • Controls. The same window with the late update on a field _0 already has, with no earlier update, or with the packet finished before the merge: each merges cleanly and ends with the values its own updates set.
The devil's-advocate check231 words

The strongest case for "not a bug": the deterministic test parks a thread inside an InfoStream callback (InfoStream is @lucene.internal) and uses expert calls (flushNextBuffer, a one-shot merge policy), so the interleaving looks artificial. The cross-model refute review raised exactly this and rejected it itself, rating the intended-behaviour argument "weak".

Why it fails: without any hook the race still happens: 20, 9 and 39 merge NPEs per 20,000 runs in the study's no-hook races; 53 per 20,000 at the introducing commit and 47 on 7.0.0, against 0 on 6.6.6 and the parent; the reviewer's own hook-free test hit the NPE in 5 of 6 runs of 20,000 attempts (first hit at attempt 244 to 3,282) and passed all 20,000 attempts in each of 5 runs with the fix. The bug was first found by a hook-free stress harness. The hook only widens a window that normal scheduling hits.

The NRT/CheckIndex symptom alone is easier to argue as intended: the reader is opened with applyAllDeletes=false while thread 2's call has not returned, and LeafReader.getBinaryDocValues may return null "if no binary doc values were indexed for this field" (LeafReader.java:176-180). But the segment's field list says BINARY while its producer returns null, which DocValuesProducer forbids, and the DocValues.getBinary helper then throws a misleading IllegalStateException; the merge NPE does not depend on that reader. Verdict: a defect.

Suggested fix

Recommended Skip fields with no applicable update in the field-creation loop (validated)

In the field-creation loop of ReadersAndUpdates.writeFieldUpdates, skip a field when none of its pending updates is applicable yet (delGen <= maxDelGen), as the method's two other loops already do (+12 lines). The test passes (60 of 60 with tests.iters=10), and so do all 8,871 tests of :lucene:core:test and :lucene:core:check -x test.

for (List<DocValuesFieldUpdates> updates : pendingDVUpdates.values()) {
  DocValuesFieldUpdates update = updates.get(0);
  boolean applicable = false;
  for (DocValuesFieldUpdates u : updates) {
    if (u.delGen <= maxDelGen) {
      applicable = true;
      break;
    }
  }
  if (applicable == false) {
    continue; // handleDVUpdates skips it too: no DV type without a dvGen
  }
  ... // unchanged: existing field, or constructFieldInfo(...)
All options and trade-offs4 options ยท 466 words

One fix was built and validated through gradle in a scratch worktree of ea57360912ed; the other options are design alternatives and were not run.

Skip fields with no applicable update in the field-creation loop (validated) (recommended)

+12 lines in ReadersAndUpdates.writeFieldUpdates (lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java, after line 916). The skipped field is added by the next writeFieldUpdates call, once its packet is finished. In the repro the merge then succeeds, and thread 2's apply loop sees that a merge finished and re-applies its packet to the merged segment ("cycle again, re-applying all our updates to the newly merged segment", IndexWriter.java:6653-6658), so the final reader shows a.tag=a-tag.

for (List<DocValuesFieldUpdates> updates : pendingDVUpdates.values()) {
  DocValuesFieldUpdates update = updates.get(0);
  boolean applicable = false;
  for (DocValuesFieldUpdates u : updates) {
    if (u.delGen <= maxDelGen) {
      applicable = true;
      break;
    }
  }
  if (applicable == false) {
    continue; // handleDVUpdates skips it too: no DV type without a dvGen
  }
  ... // unchanged: existing field, or constructFieldInfo(...)

Trade-offs

None functional found. Cost: one short scan of each field's pending update list per write; not benchmarked.

What the checks validated

  • Gradle, scratch worktree of ea57360912ed (removed afterwards), 2026-10-27: the test 6 of 6 with assertions on and off; tests.iters=10 (seed CAFEBABE) 60 of 60.
  • 22 closest org.apache.lucene.index suites (doc-values updates, soft deletes, reader pool, TestDocIDMerger, TestMergeCarryOverFromDisk, NRT, merging, threads, sequence numbers, TestAddIndexes, exceptions): 380 tests, 0 failures, 17 skipped, with assertions on and off.
  • All of :lucene:core:test (seed DEADBEEF): 8,871 tests, 0 failures, 322 skipped (the fact check's re-run kept the JUnit XML: 748 classes, 0 errors); :lucene:core:check -x test passes.
  • Earlier: 0 NPEs in the study's three 20,000-run no-hook races (20, 9 and 39 without it); the cross-model reviewer's hook-free test passed all 20,000 attempts with it in each of 5 runs (seeds A11CE and BADC0DE, assertions on and off), while 5 of 6 stock runs failed.

Compute the applicable updates once per field

Refactor writeFieldUpdates so the "any" check, the field-creation loop and handleDVUpdates share one per-field list of updates with delGen <= maxDelGen.

Trade-offs

Same behaviour as the recommended fix and removes the duplicated filter that let the three loops drift apart; a larger diff. Not run.

Hardening only: let SequentialDocIDMerger handle an empty list

Return null from next() when there is no sub.

Trade-offs

Stops this NPE, but the segment still lists a typed field whose producer returns null: CheckIndex on an NRT reader opened in the window still fails and the DocValuesProducer contract stays broken. Could accompany the fix, not replace it. Not run.

Make merges wait for every in-flight packet on their segments

Extend waitApplyForMerge to newer packets.

Trade-offs

Removes the window but brings back part of the serialization LUCENE-7868 removed (merges blocked behind update resolution). Not recommended; not run.

How it was found

A plain multi-threaded stress test with a contract oracle (reader linearizability, sequence numbers, no unexpected exception) hit it with 1 seed in 15,000 runs. A systematic concurrency tester (Fray) ran the same scenario about 4,079 times without a hit. An independent verifier then wrote a deterministic test, a hook-free race and the fix.

The method in full193 words

Method: non-formal stress testing (the 2026-10-22 study, experiment "concurrency-tools", main 7cecf79523).

  • The experiment ran concurrency tools on Lucene (Fray, Lincheck, JCStress, Java PathFinder) next to a custom NRT harness: 2-3 writer threads doing adds, updates, deletes, doc-values and soft updates, addIndexes, flush, commit and forceMerge(1), 1-2 reader threads, and a random IndexWriterConfig. Its oracle used API contracts only: linearizability of reader observations, the sequence-number contract, and no unexpected exception.
  • With two other bugs masked, 15,000 plain stress runs hit one seed (101651) whose writer closed with this NPE. Repeated 3,000 times, that seed hit it 18-23 times on main and 64-67 times on 10.5.2, 9.12.3 and 8.11.4. Fray ran the same scenario about 4,079 times (POS, PCT and SURW schedulers) without a hit.
  • An independent AI verifier wrote its own deterministic JUnit (an InfoStream hold), a no-hook race, version-portable programs (7.0.0 to main) and the one-class fix, and showed with a double dissociation that it is a different bug from LFV-001. The 2026-10-26 hunt derived the 6-test JUnit, ran a cross-model refute review, bisected the introducing commit and searched for prior reports; this doc validated the fix through gradle.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26 and in the 2026-10-22 study): GitHub issues, PRs and commits, all 408 open PRs by changed file, five mailing lists, the JIRA archive and downstream trackers. The nearest hits have other mechanisms.

Searches6 searches

Closest hits, all different:

  • elastic/elasticsearch#104617: the same NPE text, but in SortedDocIDMerger during int8 vector quantization sampling; fixed by apache/lucene#13027. Vectors, not doc-values updates.
  • #8939 (LUCENE-7888, fixed in 7.0.0): a race between merge completion and doc-values updates (lost updates) right after LUCENE-7868; this bug is at merge start and still reproduces on 7.0.0 through 10.5.2.
  • #10340 (LUCENE-9300): a different defect in the same loop (field numbers after addIndexes); its fix kept the loop unfiltered.
  • #16581 (merged 2026-09): a reviewer found sorted-numeric updates dropped or mistyped in the merge carry-over path; fixed before merge; a different path.
  • #9834 (LUCENE-8790), #9867 (LUCENE-8823), #11637 (LUCENE-10601), all open: unexplained 7.x merge failures with other frames and exceptions; nothing links them to this bug.
  • Open PRs on the same files (#16872, #16851, #15338) do not address it. #16900 (LFV-001) is a different bug with a different fix site.

Gap: GitHub search returns 50 results per query; 5 single-term queries were cut off, and each term was also searched combined with class, method or symptom terms.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26a scripted prior-report search in two passes: 101 issue/PR queries and 6 commit-message queries (classes DocIDMerger, SequentialDocIDMerger, ReadersAndUpdates, BufferedUpdatesStream; methods writeFieldUpdates, constructFieldInfo, waitApplyForMerge, handleDVUpdates; symptoms "this.current" is null, "it1" is null), comments of the top 30 read; 18 manual gh api searches, including GitHub-wide SequentialDocIDMerger and nextMappedDocno report of this bug
Open apache/lucene PRs2026-10-26all 408 open PRs by changed file35 touch the bug's files; none changes the field-creation loop, the merge-start wait or the null-producer path
Mailing lists (lists.apache.org)2026-10-26135 queries over dev@, java-user@, issues@, commits@ and builds@no report
JIRA archive and the web2026-10-2619 JIRA-archive queries, 10 web queriesno report
Downstream (Elasticsearch, OpenSearch, Solr)2026-10-265 querieselastic/elasticsearch#104617 (same NPE text, vector merge, fixed by apache/lucene#13027) and #45368 (ArrayIndexOutOfBoundsException): different bugs
Study search2026-10-2253 apache/lucene and 9 downstream queries through the REST APInot found upstream

History

Introduced in 7.0.0 by LUCENE-7868 (58105a203a19, 2017-06-21), which moved doc-values update application off the writer lock. Every release run from 7.0.0 to 10.5.2 (18) and main fails; 6.6.0, 6.6.6 and the commit's parent pass.

Versions tested7 rows

Introduced by LUCENE-7868, commit 58105a203a19 ("use multiple threads to concurrently resolve deletes and DV udpates", by mikemccand, 2017-06-21; a JIRA patch, migrated issue #8919). Before it, packets were applied under the writer lock and a merge applied all pending packets before it started, so it could never meet a half-applied packet. The commit's new field-creation loop already lacked the generation filter that its other two loops have. Later rewrites of the loop (LUCENE-9300 in 2020, LUCENE-9334 in 2021) kept the gap. The soft-delete trigger exists from 7.4.0, the first release with soft deletes. Releases older than 6.6.0 were not run.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed, a9779ef3c193 affectedthe 6-test JUnit: 3 of 6 fail (assertions on and off on ea57360912ed); 20 of 20 derived seeds
main b83101cdd2, 7cecf79523 affectedthe study's 8-test class: 8 run, the same 3 bug tests fail
10.5.2 affected3 of 6 tests fail, assertions on and off (Maven Central jars, SHA-1 checked)
7.0.0-10.3.1 (17 releases) affecteddeterministic programs (the study's portable programs and the history probe): merge NPE, writer closed; controls clean (soft-delete trigger from 7.4.0)
LUCENE-7868 58105a203a19 vs parent 1737fce5df1 bisectthe commit fails (no-hook race 53 of 20,000); the parent passes (0 of 20,000; the merge waits for the writer lock)
6.6.0, 6.6.6 not affectedthe merge blocks until the packet is fully applied; no-hook race 0 of 20,000 on 6.6.6
fix on ea57360912ed not affected6 of 6 pass; all of :lucene:core:test passes

Independent checks

Every check that ran Java says real: the study's independent verifier (own deterministic test, hook-free race, release sweep, fix), the hunt's re-verification on main and 10.5.2, a refute review by OpenAI GPT-5.6 that ran its own hook-free test, re-runs on two newer mains, and this doc's own fact check.

Checks and verdicts409 words

The first eight checks below executed Lucene code; the cross-model fact check and the diagram check read the code and the run records. The cross-model refute reviewer did not exercise the soft-delete or NRT/CheckIndex variants or the full gradle suite.

Each independent check and its verdict
CheckVerdictWhat it found
Study verifier reproduced, new bugOwn JUnit fails 3 bug tests on 9 seeds; no-hook races 20, 9 and 39 per 20,000; 17 versions 7.0.0-main; the fix gives 0; a double dissociation from LFV-001.
Public-list rerun reproduces on b83101cdd2The study's 8-test class: 8 run, the same 3 fail.
Hunt re-verification realThe 6-test JUnit fails 3 of 6 on main (-ea, -da), 10.5.2 (-ea, -da) and 20 of 20 seeds; the fix passes all 6.
Refute review (OpenAI GPT-5.6, executed) real, high confidenceWrote its own trigger test (fails on main -ea and -da; its control passes) and a hook-free stress test (its own runs: one failed at attempt 3,282 of 20,000 with -da, one passed; with the hunt's and the fact check's reruns, 5 of 6 stock runs failed, first hit at attempt 244 to 3,282); the fix passes both and 8 selected existing tests.
Main refresh still reproduces on a9779ef3c193The study's class: 8 run, the same 3 fail with the same messages.
History follow-up introducing commit confirmedThe commit fails and its parent passes, deterministically and in the no-hook race.
Fix validation (this doc) fix validatedGradle on a scratch worktree: stock 3 of 6 fail; with the fix the test, 22 closest suites and all of :lucene:core:test pass.
Fact check of this doc bug confirmed; 7 wording errors, all fixedRe-ran the gradle command (3 of 6 fail, -ea and -da), the fix (6 of 6; all of :lucene:core:test), the harness on main, 10.5.2, 9.12.3 and a9779ef3c193, the reviewer's hook-free test and a probe of the study-only claims. Every error was fixed.
Cross-model fact check (OpenAI GPT-5.6) mechanism, boundary and fix sound; wording fixedRead the code and the run records: 6 wording errors, 7 overclaims, 7 missing caveats. Most were fixed; two were not taken: softening the security rating, and the claim that DocValues.getBinary turns the null into an empty iterator, which a run refuted.
Diagram check passEvery value, API name, line number and ordering on the card matches the code and the executed runs; 8 optional cosmetic items.

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 notes614 words

Finding record

LFV-013, snapshot of 2026-11-06

Registry title
Concurrent doc-values updates can make a merge throw NullPointerException and close the writer
Area
index / doc-values updates
Priority
Medium, rank 115: A default-config race between doc-values updates on a field new to a segment and a merge throws NPE and tragically closes the writer, losing uncommitted docs, but it is loud and very rare (1 seed in 15,000 stress runs) and soft-delete-only users such as Elasticsearch and OpenSearch are unaffected.
Impact class
crash
Impact note
Default configuration; a race between doc-values updates on a field new to a segment and a concurrent merge of that segment (found by one seed, 101651, in a 15,000-run stress hunt); released since 7.0.0.
Summary
You update a doc-values field that is new to a segment while that segment is being merged. You expect the merge to finish. Instead writeFieldUpdates records the not-yet-applicable field as NUMERIC with dvGen=-1, the merge throws NullPointerException in DocIDMerger, and the IndexWriter closes tragically.
Introduced
LUCENE-7868 (58105a203a19) (2017-06-21)
First affected version
7.0.0
Affected versions
every release run from 7.0.0 to 10.5.2, and main [exec]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; a fix PR is open upstream.
Found by
non-formal baseline: stress
Upstream and prior reports
none found (study searches 2026-10-22); the open merge-NPE reports #11637 (LUCENE-10601) and #9834 (LUCENE-8790) have different frames and mechanisms (verifier)
Lucene commit tested
main ea57360912ed (2026-10-24): the 6-test JUnit through gradle with assertions on and off (3 bug tests fail, 3 controls pass) and through the repro harness on the stock jars (also 20 derived seeds), 10.5.2 and the newer main a9779ef3c193; the study's tests on main 7cecf79523 and b83101cdd2; 18 releases 7.0.0-10.5.2 with the study's portable programs and the history probe; 6.6.0, 6.6.6, the introducing commit 58105a203a19 and its parent 1737fce5df1 with a deterministic probe, and 6.6.6, the parent, the commit and 7.0.0 with a 20,000-run no-hook race; the fix in a scratch worktree of ea57360912ed (the test, 22 closest suites, all of :lucene:core:test, :lucene:core:check -x test), removed afterwards. This doc's fact checks re-ran the gradle command, the fix with all of :lucene:core:test, the harness on main, 10.5.2, 9.12.3 and a9779ef3c193, the reviewer's hook-free test (stock and with the fix), and probes of the study-only and NRT claims on main and 10.5.2.

Lucene and upstream sources

The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5112,5259,5310,6592,6613,6640
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/FrozenBufferedUpdates.java:355
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/BufferedUpdatesStream.java:178-207 (test at :188)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/ReaderPool.java:265-276 (:272)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:315-324,408,872,914-930,944,972-973
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:659-688 (:678)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SegmentDocValuesProducer.java:83-86; codecs/perfield/PerFieldDocValuesFormat.java:316-325
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/codecs/DocValuesConsumer.java:218,234,336,343; index/DocIDMerger.java:73-80,111-123; codecs/DocValuesProducer.java:45,52
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:807-858 (writeSomeDocValuesUpdates), 1664, 1938 (softUpdateDocuments, softUpdateDocument), 5030-5058 (handleMergeException), 5109-5113, 6600; index/IndexWriterConfig.java:547-549
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SegmentDocValuesProducer.java:77-81 (overlay branch); index/DocValues.java:214-230,262-268; index/MultiDocValues.java:312-336
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-168,1983-1984
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/codecs/DocValuesProducer.java:45,52; index/CheckIndex.java:3787-3795
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:322,872,922-923,973
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5030-5058,5109-5113,6108-6111; index/LeafReader.java:176-180
Suggested fix
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:6653-6658 (retry on the merged segment)

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

Repro files

About the diagrams

Summary card (bug.png) from the same repro (4 documents, fields num and tag). Every value, API name and line number on screen was checked against the code at ea57360912ed and the executed runs.

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.