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.
- Commit segments
_0 = {a}and_1 = {b}, both with a NUMERIC doc-values fieldnum. Thread 1 setsa.num = 7, adds documentc, the only one with a BINARY doc-values fieldtag, and flushes:clands in segment_2. - Thread 2 calls
updateBinaryDocValue(a, "tag", "a-tag"), addsdand flushes; it applies its update to_0itself. In the moment before it marks the update finished, thread 1 merges_0and_1. - 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 onnum, or with no first update, also merge cleanly, each with the values its own updates set. - Instead the merge first writes
_0's finished update (num = 7) but also liststagas a BINARY field of_0, with no values behind it. No merging segment has data fortag, andDocIDMergerthrowsNullPointerException: ... "this.current" is null. - The writer treats it as tragic and closes (
isOpen()is false): documentscanddand both updates are lost. The last commit, 2 documents, is intact.
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.
- Doc-values update:
updateNumericDocValue,updateBinaryDocValue,updateDocValues, or a soft delete (softUpdateDocument/softUpdateDocuments, orupdateDocValueson 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,
mergeInitwaits for the packets the merging segments depend on, then writes their finished updates to disk, so the merged segment sees them.
- Thread 2 applies the packet with
tagto_0(FrozenBufferedUpdates.java:355, called fromIndexWriter.java:6592without the writer lock). It marks it finished only later (IndexWriter.java:6640). - In between, thread 1's merge of
_0and_1starts.waitApplyForMergewaits only for packets withdelGen <=the merging segments' highest delete generation (BufferedUpdatesStream.java:188); thread 2's packet is newer, so the merge does not wait. - The merge writes
_0's pending updates up to the completed generation (IndexWriter.java:5310,ReaderPool.java:272), which covers thread 1'snumupdate but not thread 2'stagupdate. - In
ReadersAndUpdates.writeFieldUpdates, the "anything to write" check (:872) andhandleDVUpdates(: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 addstagto_0's field list throughconstructFieldInfo(:925), typed BINARY with doc-values generation -1 (FieldInfos.java:678). The new field list is written (:944). - 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 hadtag, sogetBinaryreturnsnull(PerFieldDocValuesFormat.java:322-325), althoughDocValuesProducerpromises "The return value is never {@code null}". - The merge drops the null producer (
DocValuesConsumer.java:336);_1has notageither, soDocIDMerger.ofgets an empty list, itscurrentisnull(DocIDMerger.java:116), andnext()throws (:123).handleMergeExceptionswallows nothing but aMergeAbortedExceptionof a non-external merge (IndexWriter.java:5030-5058), so the NPE reaches the outer catch ofIndexWriter.merge, which callstragicEvent(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 backgroundConcurrentMergeSchedulermerge: 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 defaultIndexWriterConfigis 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 useSerialMergeScheduler), 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 ofnullmight 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
tagpasses), and merges where the updated segment already has the late field (controltestControlLateUpdateOfExistingField) 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.
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.
/*
* 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();
}
}
}
}
# 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 nullatDocIDMerger$SequentialDocIDMerger.next, with-eaand-da.
More files, environment, full output59 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)
[...]IndexWriteris 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 returnsnullfor 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.
IndexWriterjavadoc: "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;getTragicExceptiongives "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);tagexists 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".CheckIndexrelies 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
_0already 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.
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.
+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.indexsuites (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 testpasses. - 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.
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.
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.
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.
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 andforceMerge(1), 1-2 reader threads, and a randomIndexWriterConfig. 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.
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
SortedDocIDMergerduring 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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-26 | a 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 nextMappedDoc | no report of this bug |
| Open apache/lucene PRs | 2026-10-26 | all 408 open PRs by changed file | 35 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-26 | 135 queries over dev@, java-user@, issues@, commits@ and builds@ | no report |
| JIRA archive and the web | 2026-10-26 | 19 JIRA-archive queries, 10 web queries | no report |
| Downstream (Elasticsearch, OpenSearch, Solr) | 2026-10-26 | 5 queries | elastic/elasticsearch#104617 (same NPE text, vector merge, fixed by apache/lucene#13027) and #45368 (ArrayIndexOutOfBoundsException): different bugs |
| Study search | 2026-10-22 | 53 apache/lucene and 9 downstream queries through the REST API | not found upstream |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main ea57360912ed, a9779ef3c193 | affected | the 6-test JUnit: 3 of 6 fail (assertions on and off on ea57360912ed); 20 of 20 derived seeds |
main b83101cdd2, 7cecf79523 | affected | the study's 8-test class: 8 run, the same 3 bug tests fail |
| 10.5.2 | affected | 3 of 6 tests fail, assertions on and off (Maven Central jars, SHA-1 checked) |
| 7.0.0-10.3.1 (17 releases) | affected | deterministic 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 | bisect | the 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 affected | the merge blocks until the packet is fully applied; no-hook race 0 of 20,000 on 6.6.6 |
fix on ea57360912ed | not affected | 6 of 6 pass; all of :lucene:core:test passes |
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.
| Check | Verdict | What it found |
|---|---|---|
| Study verifier | reproduced, new bug | Own 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 b83101cdd2 | The study's 8-test class: 8 run, the same 3 fail. |
| Hunt re-verification | real | The 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 confidence | Wrote 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 a9779ef3c193 | The study's class: 8 run, the same 3 fail with the same messages. |
| History follow-up | introducing commit confirmed | The commit fails and its parent passes, deterministically and in the no-hook race. |
| Fix validation (this doc) | fix validated | Gradle 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 fixed | Re-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 fixed | Read 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 | pass | Every value, API name, line number and ordering on the card matches the code and the executed runs; 8 optional cosmetic items. |
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 493 KB
Finding record, Lucene and upstream sources, repro files and diagram notes614 words
- 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 maina9779ef3c193; the study's tests on main7cecf79523andb83101cdd2; 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 commit58105a203a19and its parent1737fce5df1with 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 ofea57360912ed(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 anda9779ef3c193, 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.
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5112,5259,5310,6592,6613,6640Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/FrozenBufferedUpdates.java:355Lucene 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-973Lucene 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-325Lucene 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,52Lucene 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-549Lucene 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-1984Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/codecs/DocValuesProducer.java:45,52; index/CheckIndex.java:3787-3795Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:322,872,922-923,973Lucene 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)
- TestMergeDuringUnfinishedDocValuesUpdate.java (JUnit test):
repro/LFV-013/src/org/apache/lucene/index/TestMergeDuringUnfinishedDocValuesUpdate.java(117 lines)
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.
assets/LFV-013/bug.png(493 KB)
