All findings · Lucene finding · LFV-635 · deleteall-clears-field-schema-under-inflight-docvalues-update

A doc-values update racing IndexWriter.deleteAll() outlives its field: every later commit fails, or an unreadable field is committed

A doc-values update caught between its field check and its enqueue by deleteAll() survives, but its field does not: every later commit(), refresh and close() throws until rollback() (a tragic close with assertions on). If a document added meanwhile re-types the field, the commit writes an unreadable field instead.

Summary

Priority
Medium, rank 110A default-config core race between deleteAll and a doc-values update that can leave the writer unable to commit, refresh or close until rollback, real but rare.
Impact
data corruption
Since
4.6.0 (2013, LUCENE-5189); the NullPointerException and the committed broken segment since 9.0.0 (LUCENE-9334)introduced 2013-09-15 · over 13 years
Affected
every release from 4.6.0 through 10.6.0, and main
Status
documented
Found by
TLA+ model checking (TLC) of the writer-wide field schema, then a latched JUnit test

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

A doc-values update checks its field and queues the update in two separate steps, and nothing keeps deleteAll() out of the gap.

Background in three steps

  1. IndexWriter keeps one writer-wide field schema, globalFieldNumberMap: every field name it knows, with its number and doc-values type. Each segment also has its own FieldInfos.
  2. A doc-values update (updateDocValues, updateNumericDocValue, softUpdateDocument, ...) first checks its field in that schema, or registers it as a doc-values-only field, then queues the update. At the next NRT reopen, flush or commit the writer applies it to the matching documents; if their segment lacks the field, it copies the field's definition from the schema.
  3. deleteAll() drops every buffered document, segment and queued update, then clears the schema "to make sure it's just like a fresh index".
A flow diagram of how an Apache Lucene doc-values update reaches the index: updateDocValues() first checks its field f in the writer-wide field schema and adds it if it is new (step 1), then queues the update with the other pending changes (step 2), and at the next commit() or NRT reopen the documents are flushed into segments and the update is written into each segment, which takes the field's information from the schema (step 3); below the flow, deleteAll() drops the queued updates and then empties the schema with clear(), and a red cross between steps 1 and 2 marks where deleteAll() can run; two panels compare the expected outcome, where the update is either dropped with everything else or adds f to the new empty schema and each later commit works, with the actual outcome, where step 3 finds no f and commit, NRT reopen and close fail until rollback(), or, if a new document made f SORTED, the commit succeeds and its field f cannot be read.
A doc-values update checks its field when it is called but is written only at the next commit, so a deleteAll() between the check and the queueing leaves a queued update for a field the writer's schema no longer has. Generated with AI, checked against the code.
Diagram of the race in its new-field form (f is not in the index beforehand): thread A's updateDocValues(id:1, f=42) checks field f and adds it to the writer's field schema; thread B's deleteAll() then drops the queued updates and wipes the schema; a document with id 1 and no f is added (doc 0 of the emptied index); A queues its update, and the writer's sequence numbers put it last (deleteAll 2 < addDocument 3 < update 4). At commit the writer looks f up in the schema to write the update into doc 0's segment, finds nothing, and throws a NullPointerException (assertions off) or an AssertionError that closes the writer (assertions on).
Two threads on one IndexWriter, in the race's new-field form: the update checks (and adds) field f, deleteAll() wipes the schema, a document with id 1 is added, the update is queued, and commit() finds no f. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Default config. Add doc 0 (id:0, numeric doc-values field f = 1) and commit: f is in the writer's field schema as NUMERIC.
  2. Thread A calls updateDocValues(id:1, f = 42). It checks f against the schema (fine), and before it queues the update, thread B runs deleteAll(), which drops every segment and queued update and clears the schema. Then doc 1 (id:1, no f) is added.
  3. A now queues its update. You expect either legal order: the update came first and deleteAll() dropped it, or it came after and doc 1 gets f = 42, as on a fresh writer. The writer's own sequence numbers say deleteAll (5) < addDocument (6) < update (7).
  4. Instead commit() looks f up in the schema to write the update into doc 1's segment, finds nothing and throws NullPointerException (assertions off) on every commit, NRT reopen and close(); only rollback() recovers, losing all uncommitted work. With assertions on, an AssertionError closes the writer.
  5. If doc 1 had indexed f as SORTED doc values, the commit succeeds with assertions off and the committed segment's f throws on read; CheckIndex reports it broken and a later merge of it closes the writer.

Who is affected

Applications that call deleteAll() (or deleteDocuments(MatchAllDocsQuery)) on one thread while other threads update doc values or use soft updates on the same writer, for example a full rebuild while live partial updates continue. Default config, any scale. Rare: the window is a few statements, and re-updating or re-indexing the field before the next refresh hides it.

Mechanism, terms and numbers570 words

IndexWriter keeps one writer-wide field schema (globalFieldNumberMap). Every doc-values update (updateDocValues, updateNumericDocValue, updateBinaryDocValue, updateSortedNumericDocValue, softUpdateDocument(s)) first checks its field there, or registers it, and only afterwards queues the update, holding no lock from the check to the enqueue. deleteAll() drops the queued updates and then clears the schema. An update that checked its field before the clear and is queued after it applies to documents added after deleteAll(), and writing it then finds no field (or one of another doc-values type): with assertions off every commit, NRT reopen and close() throws NullPointerException until rollback(), with assertions on a tragic AssertionError closes the writer, and in the type-conflict case the commit succeeds and persists a segment whose field cannot be read. It affects every release from 4.6.0 through 10.6.0 and main, with the default configuration.

The gap (IndexWriter, main 5adff78bb168)

  1. updateDocValues (IndexWriter.java:2070-2079) calls buildDocValuesUpdate (:2081-2127), which checks or registers each field with globalFieldNumberMap.verifyOrCreateDvOnlyField (:2097) and then reads its value (:2109; the repro pauses here). Only then is the update queued: docWriter.updateDocValues(dvUpdates) (:2074). updateNumericDocValue (:1962-1979, check at :1964), updateBinaryDocValue (:2010) and updateSortedNumericDocValue (:2048) have the same shape; softUpdateDocument(s) build the update, then index the new document with it (:1664-1675, :1938-1948).
  2. deleteAll() (:2750-2815) locks the per-thread writers and drops the queued deletes and updates (DocumentsWriter.lockAndAbortAll, DocumentsWriter.java:266-325, deleteQueue.clear() at :295 and :302), then aborts merges, drops the segments and calls globalFieldNumberMap.clear() (IndexWriter.java:2800). Queuing an update (DocumentsWriter.applyDeleteOrUpdate, :153) is synchronized like lockAndAbortAll, but that covers only the enqueue, not the earlier check.
  3. An update that checked before the clear and is queued after it is ordered after the documents added since deleteAll(), so it applies to them.

What applying it does (ReadersAndUpdates.writeFieldUpdates)

  • The new document's segment has no f, so the code takes it from the schema: "the field is not present in this segment so we clone the global field (which is guaranteed to exist)" (ReadersAndUpdates.java:922-923). constructFieldInfo returns null for an unknown field or another doc-values type (FieldInfos.java:659-688, :664, :666).
  • Assertions on: assert fi != null (:926) throws; as an Error it is a tragic event and the writer closes itself (10.0.0 and later; on 9.x every commit throws).
  • Assertions off: byName.put(fi.name, fi) (:928) throws NullPointerException. The update stays pending, so every later commit, NRT reopen and close() fails the same way; close() rolls back, and rollback() returns to the last commit.
  • Type conflict (the new document indexed f as SORTED): the segment has f, the type check assert fi.getDocValuesType() == update.type (:920) is skipped with assertions off, and the NUMERIC update is written into the SORTED field and committed. Reading f then throws, and CheckIndex reports the segment broken. (Another doc-values type is rejected while the writer's schema has the field, FieldInfos.java:421-422; after deleteAll() the schema is empty, so the new document may legally define f as SORTED.)

Also affected: deleteDocuments(MatchAllDocsQuery), which returns deleteAll() (IndexWriter.java:1886-1890). Not affected, by code reading: tryUpdateDocValue, which is synchronized on the writer (:1721) like deleteAll's clear and only touches segments still in the writer (:1791-1829).

Exposure and likelihood338 words
  • Who is hit. Callers of deleteAll() or deleteDocuments(MatchAllDocsQuery) with concurrent updateDocValues, updateNumericDocValue, updateBinaryDocValue, updateSortedNumericDocValue (10.6.0 and main) or softUpdateDocument(s) on the same writer. Default IndexWriterConfig; the field may be new or already in the index (the repro updates an existing field). The missing-field failure is in the writer's own field-info code, before the codec is asked for its doc-values format (ReadersAndUpdates.java:914-932); the runs used the test framework's randomized codec.
  • Common form: the writer is stuck. With assertions off, every commit, NRT reopen and close() fails until rollback(), which discards everything since the last commit, including other threads' documents. The last commit on disk stays intact (in the probe: maxDoc=1, doc 0). With assertions on, the writer closes itself.
  • Worst form: a committed broken segment. When a document added inside the window gives the field another doc-values type, the commit succeeds (assertions off). Sorting or filtering on that field throws NullPointerException; other fields still read. On main and 10.5.2 the default merge policy picked the broken segment within 7 commits and the merge closed the writer; later writer sessions closed before their first commit. On 9.12.3 only forceMerge(1) hit it. Recovery: delete that segment's documents, commit and force-merge; CheckIndex is then clean.
  • Scale. None: one committed and one added document.
  • Likelihood: rare to contrived. The update must be between its check and its enqueue while deleteAll() completes and a document matching the update's term is added, and the field must not be re-registered before the next refresh or commit. A hook-free stock-API loop built to overlap the calls failed in 516 of 2,981 rounds on 1 CPU, 286 of 12,679 on 2 CPUs and 28 of 68,550 on 32 CPUs; a multi-thread stress over 1,024 field names failed in every run within 0.1-26 s, but with 1 or 8 field names no run failed in 3 x 30 s (masking). These rates describe loops built to overlap the calls, not a field frequency. The recorded searches found no user report.

Minimal repro

One JUnit test, one method, the default config, two documents. A field whose numericValue() blocks once pauses the update between its field check and its enqueue; deleteAll() and one addDocument run in that gap, then commit(). Deterministic; fails on main, 10.6.0, 10.5.2 and 9.12.3, assertions on and off.

TestDeleteAllRacingDocValuesUpdate.java (one test method)113 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.io.IOException;
import java.io.UncheckedIOException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicReference;
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;

public class TestDeleteAllRacingDocValuesUpdate extends LuceneTestCase {

  /**
   * IndexWriter reads this field's value after it has checked the field against its field schema
   * and before it queues the update. The first read pauses there until the test resumes it.
   */
  private static final class PausingNumericField extends NumericDocValuesField {
    final CountDownLatch paused = new CountDownLatch(1);
    final CountDownLatch resume = new CountDownLatch(1);

    PausingNumericField(String name, long value) {
      super(name, value);
    }

    @Override
    public Number numericValue() {
      if (paused.getCount() > 0) {
        paused.countDown();
        try {
          resume.await();
        } catch (InterruptedException e) {
          throw new RuntimeException(e);
        }
      }
      return super.numericValue();
    }
  }

  /** deleteAll() between an update's field check and its enqueue must not break commit. */
  public void testCommitAfterDeleteAllRacesDocValuesUpdate() throws Exception {
    Directory dir = new ByteBuffersDirectory();
    IndexWriter w = new IndexWriter(dir, new IndexWriterConfig());
    Document doc0 = new Document();
    doc0.add(new StringField("id", "0", Field.Store.NO));
    doc0.add(new NumericDocValuesField("f", 1));
    w.addDocument(doc0);
    w.commit(); // f exists in the index: a NUMERIC doc-values field

    PausingNumericField f = new PausingNumericField("f", 42);
    AtomicReference<Throwable> updateFailure = new AtomicReference<>();
    Thread updater =
        new Thread(
            () -> {
              try {
                w.updateDocValues(new Term("id", "1"), f);
              } catch (Throwable t) {
                updateFailure.set(t);
              }
            });
    updater.start();
    f.paused.await(); // the update has checked f, but is not queued yet

    Thread deleter =
        new Thread(
            () -> {
              try {
                w.deleteAll();
              } catch (IOException e) {
                throw new UncheckedIOException(e);
              }
            });
    deleter.start();
    deleter.join(10_000); // finishes at once today; a fix may make deleteAll wait for the update
    boolean deleteAllFirst = deleter.isAlive() == false;
    Document doc1 = new Document();
    doc1.add(new StringField("id", "1", Field.Store.NO));
    if (deleteAllFirst) {
      w.addDocument(doc1); // indexed after deleteAll, before the update is queued
    }
    f.resume.countDown();
    updater.join();
    deleter.join();
    if (deleteAllFirst == false) {
      w.addDocument(doc1);
    }
    assertNull(updateFailure.get());

    // Either order is legal: the update applies to doc1, or deleteAll dropped it. Commit must work.
    w.commit();
    w.close();
    dir.close();
  }
}
Command
cd $LUCENE
cp $REPRO/TestDeleteAllRacingDocValuesUpdate.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestDeleteAllRacingDocValuesUpdate -Ptests.seed=DEADBEEF
# assertions off (the production default): add -Ptests.asserts=false
Expected
commit() succeeds: the update either applied to doc 1 or was dropped by deleteAll().
Observed
commit() throws NullPointerException: Cannot read field "name" because "fi" is null (assertions off), or an AssertionError that closes the writer (assertions on).

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

More files, environment, full output333 words

Concurrency is the bug, so the test uses two short-lived threads; the latch (a NumericDocValuesField subclass whose first numericValue() call blocks, read by IndexWriter after the field check) only fixes the interleaving: 10 of 10 iterations with random seeds fail on main. Public API only; the test sits in org.apache.lucene.index like its neighbours, uses ByteBuffersDirectory, and compiles from 9.1.0 on (it needs the org.apache.lucene.tests packages). Like every LuceneTestCase it runs with the test framework's randomized codec (Asserting(Lucene104) in the recorded runs). For a release, run the same file against that release's lucene-core, lucene-codecs and lucene-test-framework jars.

deleter.join(10_000) lets the same test pass on a fixed writer in which deleteAll() waits for the paused update (then the test resumes the update first); with such a fix it takes about 10 s, and the test then only checks that commit() succeeds, not whether the update was applied or dropped. The type-conflict form (doc 1 indexes f as SORTED) is not part of this test. The same test is the suggested regression test; it passes with the recommended fix.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT), 10.6.0, 10.5.2, 9.12.3: fail, assertions on and off
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

# gradle, main 5adff78bb168, seed DEADBEEF, assertions on
TestDeleteAllRacingDocValuesUpdate > testCommitAfterDeleteAllRacesDocValuesUpdate FAILED
    java.lang.AssertionError
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:C0DC9C1FE044052D]:0)
        at org.apache.lucene.index.ReadersAndUpdates.writeFieldUpdates(ReadersAndUpdates.java:926)
        at org.apache.lucene.index.ReaderPool.commit(ReaderPool.java:356)
        at org.apache.lucene.index.IndexWriter.writeReaderPool(IndexWriter.java:4119)
        at org.apache.lucene.index.IndexWriter.prepareCommitInternal(IndexWriter.java:3827)
        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.TestDeleteAllRacingDocValuesUpdate.testCommitAfterDeleteAllRacesDocValuesUpdate(TestDeleteAllRacingDocValuesUpdate.java:109)

# the same, assertions off (-Ptests.asserts=false)
TestDeleteAllRacingDocValuesUpdate > testCommitAfterDeleteAllRacesDocValuesUpdate FAILED
    java.lang.NullPointerException: Cannot read field "name" because "fi" is null
        at org.apache.lucene.index.ReadersAndUpdates.writeFieldUpdates(ReadersAndUpdates.java:928)
        at org.apache.lucene.index.ReaderPool.commit(ReaderPool.java:356)
        ...

# standalone JUnit runner: 1 of 1 fails, assertions on / off, at the same two statements
# 10.6.0 ReadersAndUpdates.java:952 / :954, 10.5.2 :681 / :683, 9.12.3 :613 / :615

# after the first failure (supporting probe, not the repro)
# assertions off: commit #1-#3 and an NRT reopen throw the NPE; isOpen=true, tragic=null;
#                 close() throws the NPE and leaves the writer closed; last commit: maxDoc=1 (doc 0)
# assertions on:  commit #1 AssertionError, then AlreadyClosedException; isOpen=false, tragic=AssertionError

Why it looks unintended

  • IndexWriter is "completely thread safe" (IndexWriter.java:167), and deleteAll() is "equivalent to creating a new IndexWriter with OpenMode#CREATE" (:2740).
  • Neither legal order fails: update first, deleteAll() drops it; deleteAll() first, the update re-registers its field.
  • The code says the field "is guaranteed to exist" (ReadersAndUpdates.java:923); a similar deleteAll race (LUCENE-8785) was fixed as a bug.
The full argument253 words
  • Thread safety. Class javadoc: "IndexWriter instances are completely thread safe, meaning multiple threads can call any of its methods, concurrently" (IndexWriter.java:167-168). The deleteAll javadoc says it is "equivalent to creating a new IndexWriter with OpenMode#CREATE" (:2739-2740); its only concurrency note is that threads running forceMerge, addIndexes or forceMergeDeletes may receive MergePolicy.MergeAbortedExceptions (:2743-2745).
  • Sequence numbers. Each change returns a sequence number "which expresses the effective order in which each change was applied" (:117-118). In an independent checker's run of this race the writer reported deleteAll = 5 < addDocument = 6 < updateDocValues = 7; in that order on a fresh writer the update simply applies.
  • The invariant in the code. "the field is not present in this segment so we clone the global field (which is guaranteed to exist)", then assert fi != null (ReadersAndUpdates.java:922-926). LUCENE-9300 added the comment and an equivalent assertion (assert fieldNumbers.contains(update.field, update.type)); LUCENE-9334 turned it into constructFieldInfo(...) plus assert fi != null. In the LUCENE-9300 review (lucene-solr#1394) jpountz wrote: "the field infos of the writer should be updated when the doc-value update is performed. So the FieldInfo should always exist at this point and the doc-value type should always be correct."
  • Precedent. LUCENE-8785 (#9829, fixed in 8.1 and 7.7.2): deleteAll racing concurrent indexing "causes assertion errors and potentially broken field attributes" (lucene/CHANGES.txt:6400-6402). TestIndexWriterDelete.testDeleteAllNoDeadLock (TestIndexWriterDelete.java:326) runs deleteAll() while other threads add documents; no test runs it concurrently with a doc-values update.
The devil's-advocate check230 words

The strongest case for "undefined, so acceptable": deleteAll's concurrent semantics were never defined. Open #6070 (LUCENE-5006, 2013) asks to "define the allowed concurrent behavior eg when an app calls deleteAll while other threads are indexing"; in the LUCENE-9617 review (lucene-solr#2088) mikemccand suggested a javadoc warning that concurrent operations "may or may not be deleted, i.e. the behavior of such concurrent operations are not defined / predictable"; s1monw has asked for deleteAll to be removed; and in #9738 s1monw explains that some failures are not tragic "since a user could always call rollback()".

What to concede: which of the two calls wins is not specified, and either outcome (the update dropped, or applied to the new document) would be acceptable. Why it still fails: the warning was never added, and it would only allow the update to survive or not, not a writer that cannot commit or a committed unreadable field. In the same review rmuir called a deleteAll race that leaves "segments with unaligned field numbers" "a disaster" that would "definitely result in corruption", and mikemccand answered the removal idea with "I think we should fix its bugs instead!". deleteAll was never deprecated, and rollback() discards other threads' work after a race the application did nothing wrong in. The verifiers, including an executed works-as-designed review by another model family (OpenAI GPT-5.6), rated the argument weak. Verdict: not intended, a defect.

Suggested fix

Recommended A. A read-write lock between the update APIs and deleteAll (validated by tests)

Order every doc-values update against deleteAll() with a read-write lock: updates hold it shared from the field check until the update is queued, deleteAll() exclusively (IndexWriter.java only, 73 lines added, 5 removed). The repro and the org.apache.lucene.index tests (1,398) then pass. Its lock order and reentrancy still need review: a Field that calls deleteAll() during an update would block.

private final ReentrantReadWriteLock fieldSchemaLock = new ReentrantReadWriteLock();

public long updateDocValues(Term term, Field... updates) throws IOException {
  fieldSchemaLock.readLock().lock();
  try {
    return updateDocValuesWithFieldSchemaLock(term, updates); // the unchanged old body
  } finally {
    fieldSchemaLock.readLock().unlock();
  }
}
// The same wrapper for updateNumericDocValue, updateBinaryDocValue, updateSortedNumericDocValue;
// softUpdateDocument(s) hold the read lock around buildDocValuesUpdate + updateDocuments;
// deleteAll() holds fieldSchemaLock.writeLock() around its unchanged old body.
All options and trade-offs3 options ยท 698 words

Three options. A was built and validated on main 5adff78bb168; B and C were not built.

A. A read-write lock between the update APIs and deleteAll (validated by tests) (recommended)

Each update API (updateDocValues, updateNumericDocValue, updateBinaryDocValue, updateSortedNumericDocValue) wraps its unchanged body in the read lock; softUpdateDocument(s) take it around buildDocValuesUpdate and updateDocuments; deleteAll() wraps its unchanged body in the write lock. Each update then falls entirely before a deleteAll() (and is dropped) or entirely after it (and re-registers its field or is rejected), which also matches the sequence numbers.

private final ReentrantReadWriteLock fieldSchemaLock = new ReentrantReadWriteLock();

public long updateDocValues(Term term, Field... updates) throws IOException {
  fieldSchemaLock.readLock().lock();
  try {
    return updateDocValuesWithFieldSchemaLock(term, updates); // the unchanged old body
  } finally {
    fieldSchemaLock.readLock().unlock();
  }
}
// The same wrapper for updateNumericDocValue, updateBinaryDocValue, updateSortedNumericDocValue;
// softUpdateDocument(s) hold the read lock around buildDocValuesUpdate + updateDocuments;
// deleteAll() holds fieldSchemaLock.writeLock() around its unchanged old body.

Trade-offs

  • Every doc-values update and soft update now takes a shared read lock (a CAS on a shared word), which can contend when many threads update at once; soft updates are the per-document update path of soft-delete users. No benchmark was run, and the validation shows only that the tests pass.
  • deleteAll() now waits for in-flight updates, including a softUpdateDocument that is indexing (and possibly flushing) its document.
  • Lock order, by code reading only (not a proof): no cycle among Lucene's own code paths, because the read lock is the first lock an update takes and deleteAll() takes the write lock before fullFlushLock. Not covered: user code that re-enters the writer while an update holds the read lock. updateDocValues and the soft updates read the caller's Field objects under the lock (numericValue() and the like; the repro overrides it; the soft updates also index their documents under it), and a ReentrantReadWriteLock cannot upgrade a read lock to the write lock, so a Field whose numericValue() calls w.deleteAll() would block forever. Callbacks the writer runs on its own threads (for example a merged-segment warmer that calls an update while deleteAll() waits for its merge) were not analysed. The lock order and reentrancy still need a review, and a test with such a re-entering Field.
  • Open PR #16810 rewrites the index-sort check inside the same update methods; whichever lands second needs a rebase.

What the checks validated

  • Gradle in a clean scratch worktree of 5adff78bb168, OpenJDK 25.0.3, seed DEADBEEF: stock, the repro fails with assertions on and off; with the fix it passes on and off (about 10 s: the test waits to see whether deleteAll() blocks).
  • The whole org.apache.lucene.index test package with the repro in it: 1,398 tests (gradle's count), 0 failures, 56 skipped, in 214 test classes, including TestIndexWriter (119), TestIndexWriterDelete (29, with the concurrent testDeleteAllNoDeadLock), TestIndexWriterExceptions (40), TestIndexWriterWithThreads, TestNumericDocValuesUpdates (34), TestBinaryDocValuesUpdates (29), TestMixedDocValuesUpdates, TestDocValuesIndexing, TestSoftDeletesRetentionMergePolicy and TestIndexingSequenceNumbers.
  • :lucene:core:check -x test (google-java-format, ecj lint, forbidden APIs, licenses) passes with the fix and the test in place.
  • Earlier, on main a9779ef3c193, the same locking design made the latched tests of independent checkers pass, a 1-CPU hook-free stress fail 0 of 3,284 rounds, and a 30 s multi-thread stress (13,374 deleteAll calls, 671,147 updates) fail none.

B. Tolerate the stale update when it is applied

In writeFieldUpdates, when the schema lacks the field or has another type, re-register the field or skip the update.

Trade-offs

Small and adds nothing to the update path, but each outcome can contradict the sequence numbers: skipping drops an update the writer ordered after deleteAll(), re-registering accepts an updateNumericDocValue that the sequential order would reject, and a type conflict has no good answer.

C. Check and enqueue under DocumentsWriter's monitor, and clear the schema there

applyDeleteOrUpdate and lockAndAbortAll already synchronize on DocumentsWriter; do the field check inside that critical section and clear the schema in the same one as the queue.

Trade-offs

No new lock, but deleteAll() clears the schema only after aborting merges, and a running merge registers fields in the same schema (SegmentMerger.java:75, FieldInfos.java:788), so the clear cannot simply move earlier; updates would still have to be held off until it.

Downstream note

By code reading only (nothing executed downstream), Solr's update log serializes delete-all with in-place updates, and the Elasticsearch shard engine and its fork never call deleteAll(); applications that call it themselves alongside doc-values updates are the ones exposed.

How it was found

While a TLA+ model of IndexWriter's writer-wide field schema was being written, with a doc-values update's field check and enqueue as separate steps, code reading suggested nothing orders them against deleteAll(). TLC confirmed it with a 6-state counterexample; the model with an atomic check-and-enqueue was clean. A latched JUnit test then reproduced it on the stock jars.

The method in full123 words

Method: semi-formal (a hypothesis formed by code reading while modelling, witnessed by a model checker), on main a9779ef3c193.

  • The TLA+ model covers deleteAll, document adds, flushes, addIndexes registration and publication, and a doc-values update's field check and enqueue as separate actions, with a map from each model step to the Java file:line.
  • TLC 1.8.0 with deadlock checking on reported a violation of the invariant NoApplyFailure in a 6-state trace: field check, deleteAll, document add, flush, enqueue. A variant with background sorting failed the same way. The configurations with an atomic check-and-enqueue, and the one without deleteAll, reported no error.
  • A latched JUnit test on the stock jars then failed with assertions on and off, on main, 10.5.2 and 9.12.3.

Prior reports, history and checks

Prior reports

The recorded searches of apache/lucene issues and PRs (incl. the JIRA archive), the mailing lists, downstream trackers and the web found no public report of this defect; re-checked 2026-11-03. Related, but about other defects: lucene-solr#2088, #6070 and #9829, all on deleteAll under concurrent indexing.

Searches6 searches

No report of this defect (re-checked 2026-11-03 in two independent passes, and again for this doc). Related prior items, none of them this defect:

  • lucene-solr#2088 (LUCENE-9617, merged 2020): fixed a field-number leak in FieldNumbers.clear(). In its review rmuir asked whether the clear is safe "if another thread is calling addDocument at the same time"; the answers were that lockAndAbortAll blocks concurrent indexing and that deleteAll stops the world (writer lock, all per-thread writers locked, merges aborted). Doc-values updates, which check their field outside all of these, were not discussed and no failure was reported: a different question, whose answer this defect falls outside.
  • #6070 (LUCENE-5006, open since 2013): an umbrella asking to simplify IndexWriter/DocumentsWriter synchronization and define deleteAll's behaviour while other threads index. It names no mechanism or symptom and predates doc-values updates.
  • #9829 (LUCENE-8785, fixed in 8.1 / 7.7.2): deleteAll's schema clear raced a concurrent addDocument because per-thread states were counted before they were locked; fixed in the per-thread pool, which doc-values updates do not go through. Same class, different path and fix site.
  • #10340 (LUCENE-9300): added the "guaranteed to exist" step this race violates; #9363 (LUCENE-8316) and #10374 (LUCENE-9334): history. #9738 (LUCENE-8692, open): which exceptions are tragic; context.
  • Open PR #16810: rejects doc-values updates on segment-sort fields; edits the same update methods and keeps the check-then-enqueue. A coordination note, not a report.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub apache/lucene issues, PRs and commits (incl. the migrated JIRA archive), downstream trackers, mailing lists, JIRA, web2026-10-26/27262 recorded queries (two scripted passes, 81 GraphQL phrasings, 10 JQL queries); the closest items read in full; the changed files of all 409 open PRsno report of this defect; the related items listed above
Two independent re-checks2026-11-03re-reading the related items against this defectrelated, but about different defects
Scripted re-run for this doc2026-11-0345 queries: GitHub 16, commits 3, downstream 2, mailing lists 20, JIRA 4; 1,949 distinct hitsonly the related items above and unrelated deleteAll or doc-values history
GitHub, recent items2026-11-03searches of items updated since 2026-10-20 (deleteAll, softUpdateDocument, globalFieldNumberMap, verifyOrCreateDvOnlyField, "fi is null", ReadersAndUpdates); titles of all 70 items created since 2026-10-26; changed files of the 76 open PRs updated since 2026-10-26nothing about this defect; five open PRs touch IndexWriter.java, none orders an update against deleteAll
Web2026-11-034 queries (deleteAll with updateDocValues and NullPointerException; writeFieldUpdates "fi is null"; FieldNumbers clear race; tragic AssertionError)generic questions only
GitHub apache/lucene issues and PRs, final re-check2026-11-036 searches at doc build time (deleteAll with updateDocValues, softUpdateDocument, NullPointerException, doc values update; writeFieldUpdates with NullPointerException; verifyOrCreateDvOnlyField, cut short by the search rate limit)nothing about this defect: only #9738 (related, above), #6066 (a flush deadlock) and an unrelated index-sorting item

History

Introduced with the first doc-values update API, LUCENE-5189 (4.6.0, 2013); the NullPointerException and the silently committed broken segment since LUCENE-9334 (9.0.0). It fails on every release tested from 4.6.0 to 10.6.0 and on main, and is not fixed upstream as of 2026-11-03.

Versions tested5 rows

Introduced by LUCENE-5189 "add NumericDocValues updates" (trunk 1120e46961b, Shai Erera, 2013-09-15; branch_4x 49684b82701, 2013-11-01; migrated as #6253, no PR): updateNumericDocValue checked the field against globalFieldNumberMap and then queued the update, with nothing ordering the pair against deleteAll(). First affected release 4.6.0; 4.5.1 has no update API. deleteAll() clears the field numbers since LUCENE-4547 (5cbaddcbc7f, 4.2.0). The trigger widened with LUCENE-5680 (4.9.0, updateDocValues), LUCENE-6379 (5.2.0, deleteDocuments(MatchAllDocsQuery) delegates to deleteAll()), LUCENE-8200 (ef291c97676, 7.4.0, the soft updates softUpdateDocument(s)) and LUCENE-8316 (0c3628920af, 7.4.0, updates of new fields). The symptom changed with LUCENE-9300 (b5c5ebe37cf; 7.7.3, 8.5.1, 8.6.0: the assertion) and LUCENE-9334 (d03662c48bf, #11, 9.0.0: the NullPointerException and the silent wrong-type write). Release-level runs, no commit bisect. Upstream main head f72004515ec2 (2026-11-03): no later commit on main touches IndexWriter.java, ReadersAndUpdates.java, FieldInfos.java or DocumentsWriter.java.

Versions and branches executed, with results
Version or commitVerdictResult
4.5.1 not affectedno doc-values update API (the test does not compile)
4.6.0 - 8.11.x type conflict only (assertions off)a re-typed field: IllegalArgumentException on every commit and close until rollback(); a merely missing field is silently re-created (that form was run from 4.9.0 on; 4.6.0 - 4.8.1 ran only the type-conflict form); with assertions on from 7.7.3 / 8.5.1 / 8.6.0 also an AssertionError
9.0.0 - 9.12.3 affectedmissing field: NullPointerException on every commit, refresh and close; re-typed field: broken segment committed; assertions on: AssertionError on every commit, the writer stays open
10.0.0 - 10.6.0 affectedas 9.x with assertions off; assertions on: a tragic AssertionError closes the writer; this doc's test fails on 10.5.2 and 10.6.0
main 5adff78bb168 affectedthis doc's test fails, assertions on and off; the type-conflict form commits a segment CheckIndex reports broken

Independent checks

An executed review by another model family (OpenAI GPT-5.6), an independent reproducer, a works-as-designed adversary from that family and a devil's-advocate verifier each rated it real with their own tests; the adjudication confirmed it (high confidence). This doc's runs reproduced it on main 5adff78bb168 and three releases and validated the fix.

Checks and verdicts328 words

The reviews, verifiers and judge ran on main a9779ef3c193 (the refute-lens review on ea57360912ed, whose IndexWriter.java, FieldInfos.java and ReadersAndUpdates.java are identical); 5adff78bb168 differs from a9779ef3c193 in these files only by one IndexWriter log message. Re-runs on e1faeba08bd and, for this doc, 5adff78bb168 reproduced it.

Each independent check and its verdict
CheckVerdictWhat it found
finder foundTLC counterexample (6 states), then a latched JUnit test: 3 of 3 variants fail on main, 10.5.2 and 9.12.3, assertions on and off; 30 of 30 at 10 iterations.
pre-review, refute lens (OpenAI GPT-5.6, executed) real (high)Own latched test fails (assertions on at :926, off at :928, 3 of 3); sequential controls pass; intended argument weak.
independent reproducer realOwn test with a different latch point: 4 of 5 variants fail (new field, existing field, soft update, type conflict) on main, 10.5.2 and 9.12.3; a hook-free stock-API test fails 516 of 2,981 rounds on 1 CPU; 0 failures with the fix.
works-as-designed adversary (OpenAI GPT-5.6, executed) real (high)Own test fails with assertions on and off; control passes; intended-behaviour argument weak.
devil's-advocate verifier realExisting-field variant fails; sequence numbers deleteAll 5 < addDocument 6 < update 7; a hook-free multi-thread stress fails every run, none with the fix; masking measured.
adjudication confirmed (high)Re-ran the reproducer's suite with a new seed (4 of 5 fail, assertions on and off), the hook-free test on 1 CPU (239 of 1,396 rounds fail) and the fix (5 of 5 pass).
credibility re-check downgradedRe-ran the tests (3 of 3 fail, assertions on and off; hook-free 161 of 865 rounds); likelihood set to contrived for the corruption form, which was shown only with a latch.
this doc reproduced, fix validatedThe one-method test fails on main 5adff78bb168 (gradle and a standalone runner, 10 of 10 iterations), 10.6.0, 10.5.2 and 9.12.3, assertions on and off; the fix passes it and the org.apache.lucene.index package.

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

Finding record

LFV-635, snapshot of 2026-11-06

Registry title
A doc-values update racing IndexWriter.deleteAll() can be queued after deleteAll has cleared its field from the writer's field schema; applying it then makes every commit, NRT reopen and close() fail (a tragic close with assertions on), or, if a document added in between gives the field another doc-values type, commits a segment whose field cannot be read
Area
index / IndexWriter doc-values updates vs deleteAll (writer-wide field schema, FieldInfos.FieldNumbers)
Priority
Medium, rank 110: A default-config core race between deleteAll and a doc-values update that can leave the writer unable to commit, refresh or close until rollback, real but rare.
Impact class
data corruption
Impact note
Hits applications that call deleteAll() (or deleteDocuments(MatchAllDocsQuery), which delegates to it) on one thread while another thread updates doc values on the same writer (updateDocValues, updateNumericDocValue, updateBinaryDocValue, softUpdateDocument(s)), with the default IndexWriterConfig and codec and no scale needed: in the common form, with assertions off, every commit, NRT reopen and close() throws NullPointerException until rollback(), which discards all uncommitted work, and with assertions on the writer closes itself (main and 10.x; on 9.x every commit throws) [exec, latched and hook-free]; in the worst form, when a document indexed inside the window gives the field another doc-values type, the commit succeeds and persists a segment that CheckIndex reports broken, whose field throws on read and whose next merge closes the writer, until that segment's documents are deleted [exec, latched only]; the failure is masked when the same field is updated or indexed again before the next refresh or commit [exec].
Summary
One thread calls deleteAll() on an IndexWriter while another thread updates a doc-values field with updateDocValues, updateNumericDocValue or softUpdateDocument. You expect the update either to be dropped with everything else (if it came first) or to apply to the documents indexed after deleteAll, as on a fresh writer. Instead, when the update checked its field before deleteAll cleared the writer's field schema and is queued just after, the writer can no longer commit, refresh or close (NullPointerException with assertions off, a tragic AssertionError with them on), and when a document added in between gives the field another doc-values type, the commit succeeds but writes a segment whose field cannot be read.
Introduced
1120e46961b242104410190b70d1f4e4e57d1a40 on trunk (LUCENE-5189 'add NumericDocValues updates', migrated as apache/lucene#6253; svn era, no GitHub PR), released through the branch_4x commit 49684b82701: updateNumericDocValue checks the field against globalFieldNumberMap and then queues the update, with nothing ordering the pair against deleteAll() [read: history and line history; exec: release level only, 4.5.1 has no update API and 4.6.0 fails; no commit-level bisect]. Precondition: deleteAll() clears the writer-wide field numbers since LUCENE-4547 (5cbaddcbc7f, 4.2.0). The production symptom changed twice [exec at release level]: LUCENE-9300 (b5c5ebe37cf and backports, 7.7.3 / 8.5.1 / 8.6.0) added the 'guaranteed to exist' assertion, so the missing-field form fails with assertions on; LUCENE-9334 (d03662c48bf, apache/lucene#11, 9.0.0) turned the missing-field form into a NullPointerException with assertions off and the type-conflict form into a silent wrong-type write that commits a broken segment. LUCENE-5680 (4.9.0) and LUCENE-8316 (7.4.0) widened the trigger (updateDocValues; new fields and soft updates) (2013-09-15 on trunk (1120e46961b); 2013-11-01 on branch_4x (49684b82701))
First affected version
4.6.0 [exec: hook-free stress on 1 CPU, assertions off, 317 of 2000 commits fail with IllegalArgumentException 'cannot change DocValues type' until rollback (type-conflict form); sequential control 0 of 2000]; 4.5.1 is the last release without the bug (no doc-values update API: the repro does not compile). The worse production forms (NullPointerException on every commit for any racing update; a committed broken segment for the type-conflict form) start in 9.0.0 [exec: 8.11.4 vs 9.0.0]
Affected versions
every release from 4.6.0 through 10.6.0 and main a9779ef3c193 [exec: all 24 releases run fail (4.6.0, 4.8.1, 4.9.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.3.1, 7.4.0, 7.7.2, 7.7.3, 8.0.0, 8.5.0, 8.5.1, 8.6.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0), plus main, with assertions on and off; 10.6.0 with the one-method test on the released jars, 2026-11-03]. Symptom by version: 4.6.0-8.11.x with assertions off only the type-conflict form fails (IllegalArgumentException on every commit and close until rollback; a merely missing field is silently re-created), with assertions on from 7.7.3 / 8.5.1 / 8.6.0 the missing-field form also fails (AssertionError on every commit); 9.0.0-main with assertions off the missing-field form throws NullPointerException on every commit, NRT reopen and close and the type-conflict form commits a broken segment, with assertions on an AssertionError (tragic from 10.0.0; on 9.x the writer stays open and every commit throws) [exec]. Not fixed upstream [read]: no commit after a9779ef3c193 up to upstream head a95c754ee003 touches IndexWriter.java, FieldInfos.java, ReadersAndUpdates.java or DocumentsWriter.java, and no open PR fixes it.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: TLA+/TLC, code reading
Upstream and prior reports
no report of this bug found (2026-10-27, the prior-report record plus the finder's 2026-10-26 pass; all read-only): 262 recorded queries (two scripted prior-report search passes over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@, commits@ and builds@ lists, the JIRA archive and the web; 81 GitHub GraphQL phrasings; 10 JQL queries), the closest items read in full (body and comments), and the changed files of all 409 open apache/lucene PRs; none fixes it (#16810 keeps the unsynchronized check-then-enqueue, #15338 and #16578 widen the trigger, #16872 touches only a symptom site); upstream head a95c754ee003 leaves the involved files unchanged. Related, class level only: lucene-solr#2088 (LUCENE-9617 review, merged 2020), where a maintainer (rmuir) asked whether deleteAll's FieldNumbers.clear() is safe while another thread calls addDocument and worried about segments with unaligned field numbers, and the author answered with deleteAll's stop-the-world (doc-values updates not mentioned); open #6070 (LUCENE-5006, 2013) asks upstream to define deleteAll's behaviour while other threads index. Related, different and fixed: #9829 (LUCENE-8785, deleteAll's FieldNumbers.clear() vs concurrent addDocument, fixed in the per-thread pool in 8.1 / 7.7.2), #10340 (LUCENE-9300, added the 'guaranteed to exist' step this race violates), #9363 (LUCENE-8316) and #10374 (LUCENE-9334).
Lucene commit tested
main 5adff78bb168 (gradle in a scratch worktree and a standalone JUnit runner, assertions on and off) and the released 10.6.0, 10.5.2 and 9.12.3 jars, 2026-11-03; fix validated in the scratch worktree. Earlier runs: main a9779ef3c193 and e1faeba08bd, and 23 releases from 4.6.0 to 10.5.2.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:167-170 (at a9779ef3c193) (class javadoc: 'completely thread safe, meaning multiple threads can call any of its methods, concurrently'), :1886-1890 (deleteDocuments returns deleteAll() for a MatchAllDocsQuery, LUCENE-6379), :1962-1964 (updateNumericDocValue: verifyOrCreateDvOnlyField(field, NUMERIC, true) before the enqueue), :2070-2079 (updateDocValues: buildDocValuesUpdate, then docWriter.updateDocValues, no lock in between), :2082-2128 with :2097 (buildDocValuesUpdate: verifyOrCreateDvOnlyField(f.name(), dvType, false)), :2729-2746 (deleteAll javadoc: 'equivalent to creating a new IndexWriter with OpenMode#CREATE'; its only concurrency note is MergeAbortedException for forceMerge, addIndexes and forceMergeDeletes), :2750-2812 with :2800 (deleteAll: globalFieldNumberMap.clear())
  • lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:914-930 (at a9779ef3c193) (writeFieldUpdates: 'assert fi.getDocValuesType() == update.type' :920; a field missing from the segment is cloned from the global field '(which is guaranteed to exist)' :922-923; constructFieldInfo, 'assert fi != null' :926; fi.name dereferenced :928)
  • lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:562-657 (at a9779ef3c193) (verifyOrCreateDvOnlyField), :659-690 (constructFieldInfo returns null for an unknown field or another doc-values type)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2070-2079, (at 5adff78bb168) :2081-2127 (:2097 verifyOrCreateDvOnlyField, :2109 numericValue), :1962-1979, :2010, :2048, :1664-1675, :1938-1948, :2750-2815 (:2760-2761 'just like a fresh index', :2765 lockAndAbortAll, :2800 clear), :1886-1890, :1721, :1791-1829
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:149-165 (at 5adff78bb168) (updateDocValues, synchronized applyDeleteOrUpdate), :266-325 (lockAndAbortAll; deleteQueue.clear :295, :302)
  • lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:914-930 (at 5adff78bb168) (:920 type assert, :922-923 'guaranteed to exist', :924-926 constructFieldInfo + assert, :928 fi.name)
  • lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:421-422 (at 5adff78bb168) (addOrGet: verifySameSchema), :562-647 (verifyOrCreateDvOnlyField), :659-688 (constructFieldInfo returns null :664, :666), :694-698 (clear)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:117-118, (at 5adff78bb168) :167-168, :2729-2748 (deleteAll javadoc)
  • lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:920-926 (at 5adff78bb168)
  • lucene/CHANGES.txt:6400-6402 (at 5adff78bb168) (LUCENE-8785)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterDelete.java:326 (at 5adff78bb168) (testDeleteAllNoDeadLock)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/SegmentMerger.java:75; (at 5adff78bb168) lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:788
  • GitHub apache/lucene PR #16810 (open; changed files read 2026-11-03)
Diagrams
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2074, (at 5adff78bb168) :2097, :2800
  • lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:922-928 (at 5adff78bb168)

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

Repro files

About the diagrams

A picture of the race in its new-field form (f is not in the index beforehand, so the update's check adds it; the repro above updates an existing field), made in parallel with this doc from the code at 5adff78bb168 and executed runs; the PNG is the summary still. In the pictures the document with id 1 is doc 0 of the emptied index, and the sequence numbers are deleteAll 2 < addDocument 3 < update 4.

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.