A doc-values update checks its field and queues the update in two separate steps, and nothing keeps deleteAll() out of the gap.
- 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 ownFieldInfos. - 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. deleteAll()drops every buffered document, segment and queued update, then clears the schema "to make sure it's just like a fresh index".

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.- Default config. Add doc 0 (
id:0, numeric doc-values fieldf= 1) and commit:fis in the writer's field schema as NUMERIC. - Thread A calls
updateDocValues(id:1, f = 42). It checksfagainst the schema (fine), and before it queues the update, thread B runsdeleteAll(), which drops every segment and queued update and clears the schema. Then doc 1 (id:1, nof) is added. - 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 getsf= 42, as on a fresh writer. The writer's own sequence numbers say deleteAll (5) < addDocument (6) < update (7). - Instead
commit()looksfup in the schema to write the update into doc 1's segment, finds nothing and throwsNullPointerException(assertions off) on every commit, NRT reopen andclose(); onlyrollback()recovers, losing all uncommitted work. With assertions on, anAssertionErrorcloses the writer. - If doc 1 had indexed
fas SORTED doc values, the commit succeeds with assertions off and the committed segment'sfthrows on read; CheckIndex reports it broken and a later merge of it closes the writer.
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.
updateDocValues(IndexWriter.java:2070-2079) callsbuildDocValuesUpdate(:2081-2127), which checks or registers each field withglobalFieldNumberMap.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) andupdateSortedNumericDocValue(:2048) have the same shape;softUpdateDocument(s)build the update, then index the new document with it (:1664-1675,:1938-1948).deleteAll()(:2750-2815) locks the per-thread writers and drops the queued deletes and updates (DocumentsWriter.lockAndAbortAll,DocumentsWriter.java:266-325,deleteQueue.clear()at:295and:302), then aborts merges, drops the segments and callsglobalFieldNumberMap.clear()(IndexWriter.java:2800). Queuing an update (DocumentsWriter.applyDeleteOrUpdate,:153) issynchronizedlikelockAndAbortAll, but that covers only the enqueue, not the earlier check.- 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.
- 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).constructFieldInforeturnsnullfor an unknown field or another doc-values type (FieldInfos.java:659-688,:664,:666). - Assertions on:
assert fi != null(:926) throws; as anErrorit 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) throwsNullPointerException. The update stays pending, so every later commit, NRT reopen andclose()fails the same way;close()rolls back, androllback()returns to the last commit. - Type conflict (the new document indexed
fas SORTED): the segment hasf, the type checkassert fi.getDocValuesType() == update.type(:920) is skipped with assertions off, and the NUMERIC update is written into the SORTED field and committed. Readingfthen 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; afterdeleteAll()the schema is empty, so the new document may legally definefas 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()ordeleteDocuments(MatchAllDocsQuery)with concurrentupdateDocValues,updateNumericDocValue,updateBinaryDocValue,updateSortedNumericDocValue(10.6.0 and main) orsoftUpdateDocument(s)on the same writer. DefaultIndexWriterConfig; 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 untilrollback(), 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 onlyforceMerge(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.
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.
/*
* 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();
}
}
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 bydeleteAll().- Observed
commit()throwsNullPointerException: Cannot read field "name" because "fi" is null(assertions off), or anAssertionErrorthat closes the writer (assertions on).
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.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT), 10.6.0, 10.5.2, 9.12.3: fail, assertions on and off |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
# 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- IndexWriter is "completely thread safe" (
IndexWriter.java:167), anddeleteAll()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). ThedeleteAlljavadoc says it is "equivalent to creating a new IndexWriter with OpenMode#CREATE" (:2739-2740); its only concurrency note is that threads runningforceMerge,addIndexesorforceMergeDeletesmay receiveMergePolicy.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 intoconstructFieldInfo(...)plusassert 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) runsdeleteAll()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.
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.
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 asoftUpdateDocumentthat 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 beforefullFlushLock. Not covered: user code that re-enters the writer while an update holds the read lock.updateDocValuesand the soft updates read the caller'sFieldobjects under the lock (numericValue()and the like; the repro overrides it; the soft updates also index their documents under it), and aReentrantReadWriteLockcannot upgrade a read lock to the write lock, so aFieldwhosenumericValue()callsw.deleteAll()would block forever. Callbacks the writer runs on its own threads (for example a merged-segment warmer that calls an update whiledeleteAll()waits for its merge) were not analysed. The lock order and reentrancy still need a review, and a test with such a re-enteringField. - 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 whetherdeleteAll()blocks). - The whole
org.apache.lucene.indextest package with the repro in it: 1,398 tests (gradle's count), 0 failures, 56 skipped, in 214 test classes, includingTestIndexWriter(119),TestIndexWriterDelete(29, with the concurrenttestDeleteAllNoDeadLock),TestIndexWriterExceptions(40),TestIndexWriterWithThreads,TestNumericDocValuesUpdates(34),TestBinaryDocValuesUpdates(29),TestMixedDocValuesUpdates,TestDocValuesIndexing,TestSoftDeletesRetentionMergePolicyandTestIndexingSequenceNumbers. :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,374deleteAllcalls, 671,147 updates) fail none.
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.
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.
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.
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,addIndexesregistration and publication, and a doc-values update's field check and enqueue as separate actions, with a map from each model step to the Javafile: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.
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 thatlockAndAbortAllblocks 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
addDocumentbecause 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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub apache/lucene issues, PRs and commits (incl. the migrated JIRA archive), downstream trackers, mailing lists, JIRA, web | 2026-10-26/27 | 262 recorded queries (two scripted passes, 81 GraphQL phrasings, 10 JQL queries); the closest items read in full; the changed files of all 409 open PRs | no report of this defect; the related items listed above |
| Two independent re-checks | 2026-11-03 | re-reading the related items against this defect | related, but about different defects |
| Scripted re-run for this doc | 2026-11-03 | 45 queries: GitHub 16, commits 3, downstream 2, mailing lists 20, JIRA 4; 1,949 distinct hits | only the related items above and unrelated deleteAll or doc-values history |
| GitHub, recent items | 2026-11-03 | searches 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-26 | nothing about this defect; five open PRs touch IndexWriter.java, none orders an update against deleteAll |
| Web | 2026-11-03 | 4 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-check | 2026-11-03 | 6 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 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 4.5.1 | not affected | no 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 | affected | missing 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 | affected | as 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 | affected | this doc's test fails, assertions on and off; the type-conflict form commits a segment CheckIndex reports broken |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | found | TLC 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 | real | Own 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 | real | Existing-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 | downgraded | Re-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 validated | The 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. |
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 532 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,464 words
- 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: maina9779ef3c193ande1faeba08bd, and 23 releases from 4.6.0 to 10.5.2.
- 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-1829lucene/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:788GitHub 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, :2800lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:922-928 (at 5adff78bb168)
- TestDeleteAllRacingDocValuesUpdate.java (one test method):
repro/LFV-635/TestDeleteAllRacingDocValuesUpdate.java(113 lines)
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.
assets/LFV-635/bug.png(532 KB)
