All findings · Lucene finding · LFV-010 · try-undelete-ignored-by-buffered-ops

A tryUpdateDocValue un-delete is ignored or lost by later term-based operations

With soft deletes, un-delete a document with tryUpdateDocValue, then replace it by term or delete the rest of its segment before the un-delete is written: the commit holds 2 live copies of the key, or loses the revived document. Every release since 7.4.0 has it.

Summary

Priority
Low, rank 261Needs the opt-in soft-deletes field plus an expert try* un-delete followed by term operations before a refresh, a pattern the IndexWriterConfig javadoc says has no API support.
Impact
data corruption
Since
7.4.0 (LUCENE-8298, 2018)introduced 2018-05-09 · about 8 years
Affected
7.4.0 to 10.5.2 (all 67 releases) and main
Status
documented
Found by
model-based randomized testing; formal tools re-found it (hinted)

AI-assisted page: this finding was first signalled by a testing baseline (randomized, fuzz, property-based or stress testing, or static analysis) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

The steps below are the duplicate-key case (K1c) and the lost-document case (K2); the full explanation also covers K1 and the contested C2.

Summary card with two cases. Case 1, ignored: updateDocValues(id:x, SOFT=1) gets sequence number 5 and marks x deleted in the writer's live docs; tryUpdateDocValue(r, doc 0, SOFT=null) un-deletes x with sequence number 7 but the reset is only queued; softUpdateDocument(id:x, new x, SOFT=2) gets 8, and at commit (10) its term update skips old x because the live docs still say deleted, then the reset is written, so the commit holds 2 live documents with id x instead of 1. Case 2, lost: segment _0 holds y and x indexed with SOFT=1; tryUpdateDocValue(r, doc 1, SOFT=null) un-deletes x with sequence number 5 but x is still counted as deleted; deleteDocuments(id:y) gets 6; at commit (7) the segment counts 2 of 2 deleted, is dropped as 100% deleted, and x is gone. CheckIndex is clean in both cases; opening an NRT reader right after the un-delete gives the documented result.
The two main cases on one card: the pending un-delete (blue) against the writer's live docs and deleted count, which still say deleted (orange). Select the still to open it at full size. Generated with AI, checked against the code.
  1. Set a soft-deletes field (setSoftDeletesField("soft_delete"), NoMergePolicy). Index x and y and commit, soft-delete x by term (updateDocValues(id:x, soft=1), sequence number 5) and open an NRT reader r: x is hidden.
  2. Un-delete x with tryUpdateDocValue(r, doc 0, soft=null), as its javadoc describes: it succeeds with sequence number 7. Then replace x with softUpdateDocument(id:x, new x, soft=2) (8) and commit (10).
  3. You expect 1 live x, the new one: the replace comes after the un-delete.
  4. Instead the commit holds 2 live x. The un-delete only waits in the segment's pending doc-values updates. At commit the replace's term update is resolved first, against the writer's live docs, which still say old x is deleted, so it skips old x; then the un-delete is written.
  5. The same gap loses a document: segment {y, x indexed soft-deleted}, un-delete x (5), delete y (6), commit (7). The deleted count still counts x, so the segment looks 100% deleted and is dropped with x. No exception, CheckIndex clean; an NRT reader opened right after the un-delete gives the documented result.

Who is affected

Applications with the opt-in soft-deletes field that change a document's soft value with the expert tryUpdateDocValue (to un-delete it, its documented use, or to soft-delete it) and then, before that is written, touch the document by term or delete the rest of its segment. No callers in Elasticsearch, OpenSearch or Solr were found; frequency was not measured.

Mechanism, terms and numbers897 words

With a soft-deletes field, un-delete a document with tryUpdateDocValue(reader, doc, soft_delete=null), the way its javadoc suggests, and then touch it again by term before the writer writes the pending update (for example at the next refresh, flush or commit): the later operation does not see the un-delete. In the repro the soft delete gets sequence number 5, the un-delete 7 (success) and a second soft delete by term 8, yet a fresh reader and the commit still show 1 live copy of the document instead of 0. With softUpdateDocument as the later call the commit holds 2 live copies of the same key, and deleting the other document of its segment drops the revived document altogether. Lucene raises no exception, and CheckIndex passes on the committed index in the two tests that run it. The repro needs two documents and the opt-in soft-deletes field, and every release from 7.4.0 to 10.5.2 has it.

What you do

Lucene can soft-delete a document instead of removing it. You pick a doc-values field with IndexWriterConfig.setSoftDeletesField("soft_delete"); a document that has a value in that field counts as deleted, and readers from the writer hide it. You soft-delete by term with updateDocValues(id:x, soft_delete=1), or with softUpdateDocument, which marks the old copy and adds a new one.

To bring a soft-deleted document back, you remove its value with the "Expert" method tryUpdateDocValue(reader, docID, soft_delete=null). Its javadoc says: "This can be used to un-delete a soft-deleted document". It addresses the document by its doc ID in an NRT reader of the same writer and returns a sequence number (> 0 means success). The IndexWriter javadoc says sequence numbers express "the effective order in which each change was applied".

A tiny example

Two documents, id:x and id:y, no merges:

stepcallreturnslive copies of x you expect
1add x and y, commit()seq 1, 2; commit 41
2updateDocValues(id:x, soft_delete=1): soft deleteseq 50
3r = DirectoryReader.open(writer)x is hidden in r0
4tryUpdateDocValue(r, docOf(x), soft_delete=null): un-deleteseq 7, success1
5updateDocValues(id:x, soft_delete=2): soft delete againseq 80
6open a fresh NRT reader, then commit()0

What you get: 1 live copy of x, in the fresh reader and in the commit. The step-5 delete never reached x. If you open an NRT reader between steps 4 and 5, you get 0. (Sequence numbers 3 and 6 are not hidden operations: the commit's and the reader's full flushes reserve them.)

Two more versions of step 5 show what this does to real data:

  • Duplicate key. Step 5 is softUpdateDocument(id:x, new x, soft_delete=2), the usual way to replace a record when you use soft deletes. You expect 1 live x (the new one) and get 2, in the commit too. The same happens when every document is indexed with softUpdateDocument, as the soft-deletes javadoc prescribes.
  • Lost document. Start from a segment that holds y and a soft-deleted x, un-delete x, then deleteDocuments(id:y) and commit. You expect x to be live and get no x at all. This also happens with a plain default writer config and a larger segment.

Why: the un-delete waits in a queue

For each segment the writer keeps a bitmap of live documents and a deleted-document count, and next to them a queue of doc-values changes that are not written yet.

  1. The un-delete only joins the queue. tryUpdateDocValue hands its change to the segment with rld.addDVUpdate(updates) (IndexWriter.java:1775), which just appends it (ReadersAndUpdates.java:153-164). The bitmap still says "x deleted". Its twin tryDeleteDocument is different: it clears the live bit at once (IndexWriter.java:1694).
  2. The bitmap catches up only when the queue is written. Writing calls onDocValuesUpdate (ReadersAndUpdates.java:429), which re-applies the soft-delete field to the bitmap and the count (PendingSoftDeletes.java:151-167). That happens, for example, at the next NRT reopen, flush, commit or close, when a merge starts, or under RAM pressure.
  3. Term-based updates consult the bitmap (the duplicate key). A buffered update by term only touches documents the bitmap calls live: acceptDocs = segState.rld.getLiveDocs() (FrozenBufferedUpdates.java:321, used at :327 and :341). x looks deleted, so the step-5 soft delete, or the soft-delete half of softUpdateDocument, skips it. Even the next reopen resolves the buffered step 5 (IndexWriter.java:596) before it writes the queued un-delete (:608).
  4. The count still counts x (the lost document). A segment is "fully deleted" when getDelCount() == maxDoc (PendingDeletes.java:219-221), and the count includes the soft-deleted x (:243-246). After y is deleted the segment counts 2 of 2 deleted and is dropped (IndexWriter.java:6750-6752, :6726-6733), and the queued un-delete goes with it.

So the outcome depends on whether something wrote the queue between the two calls, which is why it can look intermittent.

A related, contested variant (C2). The same lag applies to two soft deletes by term with no un-delete: updateDocValues(id:x, soft_delete=1) and then soft_delete=2 commit x with value 2, but with 1 if an NRT reader was opened in between. Lucene's own tests expect term updates to skip documents whose soft delete is already written, so which answer is right when nothing was written is a question for the maintainers; the fix below does not change it. No javadoc promises refresh independence for this case.

Exposure and likelihood353 words
  • Who is hit. Applications with a soft-deletes field that change a document's soft-delete value with tryUpdateDocValue (to un-delete it, the javadoc's stated use, or to soft-delete it) and then, before the pending update is written (for example by the next NRT reopen, flush, commit or merge, or a RAM-triggered write), either touch the same document by term (updateDocValues, updateNumericDocValue, softUpdateDocument) or delete the other live documents of its segment (deleteDocuments, tryDeleteDocument). A plausible pattern: "restore this record, then replace it", that is, an un-delete followed by softUpdateDocument on the same key.
  • What breaks. Two live documents with the same key (K1c); a later delete or update that silently misses (K1; K1b: a numeric or binary update by term keeps the old value); a revived document that disappears from the commit (K2). The mirror case also exists: after a soft delete with tryUpdateDocValue, a term-based numeric update reaches the soft-deleted copy only if no reader was opened in between.
  • Opt-in feature, default settings otherwise. Soft deletes are opt-in and tryUpdateDocValue is an "Expert" public method. Default codec. K1 also fails under SoftDeletesRetentionMergePolicy; K2 needs a merge policy that drops fully deleted segments, which is the default; it was also run with a plain default writer config.
  • No scale needed, deterministic. Two documents and a handful of calls; the same result on all 6 seeds tried and in 10 of 10 iterations. The writer config is fixed; the test framework still randomizes the directory and the codec, so this is tested robustness, not a proof.
  • Silent and durable. Lucene raises no exception and no Lucene-internal assertion fires under -ea (only the test's own assertions report it); CheckIndex passes on the K1c and K2 commits; the wrong state is committed. Anything that writes the pending update between the two calls hides it, so it can look intermittent.
  • Reach. A code search found no tryUpdateDocValue callers in Elasticsearch, OpenSearch or Solr, so the known exposure is custom applications using the expert API (the search says nothing about private code). How often applications hit the window was not measured.

Minimal repro

The test shows that with a soft-deletes field, a soft delete by term issued after a successful tryUpdateDocValue(soft=null) un-delete is skipped, because it is resolved against live docs that the pending un-delete has not reached yet.

TestTryUndeleteThenSoftDelete.java (JUnit test)62 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.index;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestTryUndeleteThenSoftDelete extends LuceneTestCase {

  public void testSoftDeleteAfterTryUndeleteIsApplied() throws Exception {
    Term x = new Term("id", "x");
    IndexWriterConfig config =
        new IndexWriterConfig()
            .setSoftDeletesField("soft")
            .setMergePolicy(NoMergePolicy.INSTANCE)
            .setCodec(TestUtil.getDefaultCodec());
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, config)) {
      for (String id : new String[] {"x", "y"}) {
        Document doc = new Document();
        doc.add(new StringField("id", id, Field.Store.NO));
        w.addDocument(doc);
      }
      w.commit();
      w.updateDocValues(x, new NumericDocValuesField("soft", 1));
      long undelete, delete;
      try (DirectoryReader r = DirectoryReader.open(w)) {
        undelete = w.tryUpdateDocValue(r, 0, new NumericDocValuesField("soft", null));
        delete = w.updateDocValues(x, new NumericDocValuesField("soft", 2));
      }
      try (DirectoryReader r = DirectoryReader.open(w)) {
        // expected: the soft delete issued after the un-delete deletes x; actual: x stays live
        assertEquals(
            "live x after un-delete (seq " + undelete + ") then soft delete (seq " + delete + ")",
            0,
            new IndexSearcher(r).count(new TermQuery(x)));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-010/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

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

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.index.TestTryUndeleteThenSoftDelete
Expected vs actual
Expected: x is deleted after the un-delete (seq 7) and the later soft delete (seq 8), so 0 live x. Actual (-ea and -da): 1 live x (expected:<0> but was:<1>).

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

More files, environment, full output44 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: live x after un-delete (seq 7) then soft delete (seq 8) expected:<0> but was:<1>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.index.TestTryUndeleteThenSoftDelete.testSoftDeleteAfterTryUndeleteIsApplied(TestTryUndeleteThenSoftDelete.java:55)

Why it looks unintended

  • The javadoc: "This can be used to un-delete a soft-deleted document"; the call succeeds (sequence number 7).
  • Sequence numbers give "the effective order in which each change was applied"; the later replace (8) acts as if the un-delete had not happened.
  • No javadoc asks for a refresh in between, and the lost-document form discards a successful un-delete outright.
The full argument377 words
  • The method's contract. tryUpdateDocValue (IndexWriter.java:1707-1720): for an NRT reader of this writer whose segment was not merged away "the update succeeds and this method returns a valid (> 0) sequence number"; a null value removes the existing value, and "This can be used to un-delete a soft-deleted document since this method will apply the field update even if the document is marked as deleted." CHANGES.txt:7390-7393 (LUCENE-8298): "This allows to undelete a soft-deleted document unless it's been claimed by a merge." Nothing is merged in the repro.
  • The ordering contract. IndexWriter.java:117-118: a sequence number "expresses the effective order in which each change was applied". The skipped delete has sequence number 8, after the un-delete's 7. The javadoc does not define which documents a later term operation selects, but a later call that behaves as if the earlier, successful one had not happened appears inconsistent with that order.
  • What term operations promise. updateDocValues (IndexWriter.java:2060-2061): "Each field update is applied to the set of documents that are associated with the Term". softUpdateDocument (:1918-1919): "first updating the document(s) containing term". Neither excepts a just-revived document.
  • Soft equals hard. IndexWriterConfig.java:543-545: a soft-deleted document "is treated as if it has been hard-deleted". The hard twin, tryDeleteDocument, changes the live docs at once, so later term operations see it.
  • No upstream test issues a term operation while an un-delete is pending. TestSoftDeletesRetentionMergePolicy#testUndeleteDocument (:595-625) opens a new NRT reader right after the un-delete (:620); the only two helpers that call tryUpdateDocValue (TestSoftDeletesRetentionMergePolicy.java:781-792, TestMixedDocValuesUpdates.java:589-599) open a fresh reader before every call. The nearest case, testMixedSoftDeletesAndHardDeletes (:696-745), may issue a buffered delete (:735) right after a try soft delete (:721); that is not one of this bug's forms (no un-delete, and term hard deletes are not filtered by the soft live docs).
  • History. LUCENE-8290 (#9337, committed 2018-05-03) moved the soft-delete bookkeeping to write time "to be consistent with what is actually updated on disk", before un-delete existed. LUCENE-8298 (#9345, by @s1monw, +1 from @mikemccand and @jpountz; 2018-05-09) added un-delete without revisiting that; its thread does not discuss later operations issued while the reset is pending. #8398 (LUCENE-7344, open) is the same design family for delete-by-query.
The devil's-advocate check430 words

Strongest case for "intended": IndexWriterConfig.java:557-559 says "Currently there is no API support to un-delete a soft-deleted document" and tells you to re-index instead; the same javadoc says documents "must be indexed via IndexWriter#softUpdateDocument" (IndexWriterConfig.java:547-548), and the repro uses addDocument. The tryUpdateDocValue NOTE says it "can only update documents visible to the currently open NRT reader" (IndexWriter.java:1717-1719). Term operations resolve against the written live docs, and sequence numbers order operations without defining their target set. LUCENE-8290 deliberately keeps the live docs in sync with what is on disk, so term operations see the written state, and an application using the Expert try* API could be expected to refresh after it. The independent reviewer from another model family (OpenAI GPT-5.6) built this argument itself and rated it moderate. The cross-model fact check of this page rated it moderate for K1/K1b/K1c, weak for K2, strong for C2 and moderate for the mirror case.

Why it fails: that IndexWriterConfig paragraph predates the feature (it came with LUCENE-8233 and LUCENE-8298 never updated it); the later method javadoc, the CHANGES entry and the upstream un-delete test all support un-delete through tryUpdateDocValue. No javadoc states a refresh precondition; the call reports success with a sequence number that orders it first, and the result then follows hidden buffer state (the same calls are right after any refresh). The hard-delete twin is visible at once, and soft deletes are documented to behave like hard ones. Indexing with softUpdateDocument changes nothing: K1, K1c and K2 were reproduced that way. "Visible" in the NOTE contrasts the reader's segments with documents indexed after it (its next sentence); the same javadoc says the update applies "even if the document is marked as deleted", and the upstream helper finds the soft-deleted document through an IncludeSoftDeletesWrapper. The lost-document form is more than a visibility question: a successful, pending un-delete is discarded with its segment because the deleted count still counts x. The reviewer reached "real, high confidence" for the same reasons. Verdict: not intended for the un-delete forms (K1, K1c, K2).

Where "intended" has a point: C2. TestSoftDeletesDirectoryReaderWrapper#testReuseUnchangedLeafReader (:94-165) expects a term-based soft update to leave an already soft-deleted, written document alone. C2 asks what two term-based soft deletes should give when nothing was written in between; no API promises refresh independence for this case. The study's verifiers split 4 "new bug" / 2 "intended" on a related form that un-deletes by term (updateDocValues(term, soft=null)); the soft=1-then-soft=2 form in the test was added later as adjudication evidence and was not voted on by itself.

Suggested fix

Recommended B: apply a try* soft-field change to the live docs right away

Option B: tryUpdateDocValue applies a soft-field change to the writer's live docs and deleted count at once, as tryDeleteDocument does, and still queues it for writing (+33/-1 in 4 files). K1, K1c and K2 pass, and so does the org.apache.lucene.index package except C2, which it does not change. Not covered: a try update during a running merge (read, not tested).

All options and trade-offs5 options ยท 1,168 words

For the un-delete forms the gap is that a successful try* change to the soft-deletes field reaches neither the live docs nor the deleted count until it is written. A fix either makes it visible at once (option B, validated) or writes it at once (option A, the study's diagnostic). C2 needs a separate decision. Option B was built and validated in a scratch worktree at ea57360912ed (removed afterwards); the others are diagnostics or untested ideas.

B: apply a try* soft-field change to the live docs right away (validated by the checks) (recommended)

tryUpdateDocValue hands its resolved update to a new ReadersAndUpdates.addTryDVUpdate, which still queues it for writing and, if it touches the soft-deletes field, also applies it to the pending live docs and the pending soft-delete count, the way tryDeleteDocument applies its delete. When the pending updates are written later, onDocValuesUpdate applies the newest value of each document again: if the try update is still the newest, nothing flips (applySoftDeletes counts only bits it changes); if a later update of the same document exists, as in K1, the newer value wins, which is the intended result. +33/-1 lines in IndexWriter.java, ReadersAndUpdates.java, PendingDeletes.java and PendingSoftDeletes.java.

// IndexWriter.tryUpdateDocValue
-              rld.addDVUpdate(updates);
+              rld.addTryDVUpdate(updates);

// ReadersAndUpdates
public synchronized void addTryDVUpdate(DocValuesFieldUpdates update) throws IOException {
  if (reader == null) {
    getReader(IOContext.DEFAULT).decRef(); // pass a reader to initialize the pending deletes
  }
  addDVUpdate(update);
  pendingDeletes.onTryDocValuesUpdate(update);   // no-op in PendingDeletes
}

// PendingSoftDeletes
@Override
void onTryDocValuesUpdate(DocValuesFieldUpdates update) throws IOException {
  if (this.field.equals(update.field)) {
    pendingDeleteCount += applySoftDeletes(update.iterator(), getMutableBits());
    assert assertPendingDeletes();
  }
}

Trade-offs

  • It brings back, for the try* path only, the eager bookkeeping that LUCENE-8290 removed for all updates because a retention merge policy could "see a deleted document but not it's value on disk". Updates pending when a merge starts are written before the merge reads the segment (IndexWriter.java:5310), and pending updates are written before an NRT reader is opened (:608). Merge selection can still see the difference: SoftDeletesRetentionMergePolicy.numDeletesToMerge (:154-183) runs its retention query on the on-disk values while the count already includes a pending try soft delete, so it can over-count reclaimable documents until the update is written (read, not tested).
  • Not covered (read, not tested): a try update that arrives while a merge of its segment is running is carried over to the merged segment with plain addDVUpdate (IndexWriter.java:4531-4540), not addTryDVUpdate, so on the merged segment the window can open again until the update is written. A complete fix would also have to handle that, or document it. There is no concurrent test for try updates during a merge, merge carry-over, abort or rollback.
  • NRT readers that are already open keep their point-in-time live docs; option B changes what later writer operations resolve against.
  • No new doc-values generation per call (unlike option A); the added work is the in-memory bit update and, when the segment has no pooled reader yet, a defensive getReader. A small cost probe (1,000 rounds of a try soft-field change plus a term-based numeric update, in-memory directory, 3 runs of 3 rounds) found no clear slowdown: steady-state rounds took 57-155 ms with option B and 37-219 ms on stock, so the ranges overlap. Nine samples are not a benchmark.
  • tryUpdateDocValue on a document that is also hard-deleted sets its live bit back: that is the separate bug LFV-008 (applySoftDeletes ignores hard deletes), now hit inside the try call with assertions on (stock: at the next NRT open); with assertions off both fail at the next NRT open (executed by the fact check). If LFV-008's fix lands, the new call should pass the hard live docs too (not tested together).
  • Does not fix C2 or the sister bug LFV-009 (an earlier buffered update overwriting a try update).

What the checks validated

  • The repro: K1, K1c, K2 and the control pass with assertions on and off; only C2 still fails (it has no try* call).
  • The study's original 15-test repro (10 bug tests including K1b, K2b, K2c and K2d, plus 5 controls): 15/15 pass with -ea and -da (stock: 10 of 15 fail). The reviewer's own repro: 2/2 pass. The edge test: 5/5 pass.
  • :lucene:core:test on 22 targeted suites (the repro, the edge test, TestPendingSoftDeletes, TestPendingDeletes, TestSoftDeletesRetentionMergePolicy, TestSoftDeletesDirectoryReaderWrapper, TestMixedDocValuesUpdates, TestNumericDocValuesUpdates, TestBinaryDocValuesUpdates, TestSortedNumericDocValuesUpdates, TestIncrementalDocValuesUpdates, TestDocValuesFieldUpdates, TestMergeCarryOverFromDisk, TestIndexWriterMergePolicy, TestTryDelete, TestIndexWriterDelete, TestIndexWriterReader, TestIndexingSequenceNumbers, TestIndexWriter, TestStressNRT, TestCheckIndex, TestAddIndexes): 408 tests, 1 failure (C2), with assertions on and with -Ptests.asserts=false.
  • --tests 'org.apache.lucene.index.*': 215 test classes, 1,404 tests as Gradle counts them, 1 failure (C2), 56 skipped.
  • :lucene:core:check -x test passes (google-java-format, ecjLint, forbiddenApis) after tidy re-wrapped one javadoc sentence of the patch and a few test lines.
  • After two comment corrections on 2026-10-26 (no code change): the changed files are google-java-format clean, and the repro (-ea, -da), the edge test and the study's 15-test repro give the same results.

A: write the try* soft-field update through at once (study diagnostic)

After a try update that touches the soft-deletes field, call rld.writeFieldUpdates(...) and checkpoint() inside tryUpdateDocValue (+18 lines in IndexWriter.java). Partly checked: on ea57360912ed K1, K1c and K2 pass and C2 fails; in the study (main 7cecf79523) its 15-test repro and 19 upstream soft-deletes tests passed. Not run against the module test suites here.

Trade-offs

  • A new doc-values generation (files) per call. In the same cost probe steady-state rounds took 404-1,184 ms against 37-219 ms on stock.
  • It writes only updates whose delete generation is at most completedDelGen (ReadersAndUpdates.java:870-882), so while another thread is still applying an older packet, the new try update is not written (other eligible pending updates may be) and the window stays open (read, not tested).

C: getHardLiveDocs() at FrozenBufferedUpdates.java:321 (diagnostic only)

Term-based updates would then reach soft-deleted documents too. K1, K1c and C2 pass, K2 still fails.

Trade-offs

Not a fix: it breaks TestSoftDeletesDirectoryReaderWrapper#testReuseUnchangedLeafReader (its assertSame on the unchanged leaf fails; the stock run passes), so it changes intended semantics.

D: for C2, make term-resolved soft deletes visible to later resolution (untested)

If the maintainers agree that a later term-based soft update should skip a document that an earlier, unwritten soft delete already covers (what testReuseUnchangedLeafReader expects once it is written), term-resolved soft-field updates must reach the live docs before later resolution.

Trade-offs

Needs the eager hook for those updates too, which undoes LUCENE-8290's move, plus tracking inside one packet, because a packet's updates are handed to the segment only after all of them are resolved (FrozenBufferedUpdates.java:351-357). A design question for the issue.

E: document the behaviour (untested)

State on tryUpdateDocValue and setSoftDeletesField that a try* soft-field change is not seen by term-based operations, and can be lost with its segment, until the pending update is written.

Trade-offs

The cheapest change, but it contradicts the sequence-number contract and makes K2 documented data loss. Independent of any fix, the stale IndexWriterConfig.java:557-559 paragraph should be updated.

Downstream note

Not attacker-driven: the application issues the un-delete and the later calls itself, so it is out of scope of SECURITY.md. Downstream, a delete or replace by term that skips a revived document leaves it searchable: a system that relies on deletes for access control or retention could show a record it deleted (hypothetical, not tested).

How it was found

Model-based randomized testing: an AI-written harness compared every reader with an executable reference model of the writer and flagged this 704 s into a run (5 s combined with LFV-009's operations). TLA+, Alloy, P, Dafny, JBMC and OpenJML re-found it when hinted. The study was pointed at this area by an earlier bug, not at this one.

The method in full166 words

Method: model-based randomized testing (the study's non-formal baseline); formal tools re-found it after being told about it.

  • A 2026-10-22 study of Lucene by AI tools covered the IndexWriter/NRT layer because an earlier bug there (now #16900) was the study's starting point. So the area was hinted; this bug was not.
  • An AI tool wrote a model-based testing harness: an executable reference model of the writer, random operation sequences, and a comparison of every reader against the model. Its model-value comparison flagged this bug 704 s into a run on its own, and after 5 s when combined with the operations of the sister bug LFV-009.
  • TLA+ (the TLC model checker), Alloy, P, Dafny, JBMC and OpenJML checks then re-found it with hints; the TLC model also pinned the root cause of the lost-document form.
  • Independent AI verifiers reproduced it and voted "new bug"; this hunt re-verified it on ea57360912ed, ran the release history and the prior-art search, had it reviewed adversarially, and validated a fix.

Prior reports, history and checks

Prior reports

No matching report found: 183 queries over GitHub issues and PRs, mailing lists, JIRA, commits, downstream code and the web, and no open PR that changes this path (2026-10-25); upstream has not fixed it. Related: #8398 (open, the same family) and #9337/#9345 (the history).

Searches6 searches

No report of this defect was found, and upstream has not fixed it. The stale IndexWriterConfig javadoc is unreported too.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene), with migrated JIRA issues2026-10-2590 queries from the scripted prior-report search (class and method names such as tryUpdateDocValue(s), PendingSoftDeletes, getHardLiveDocs, applySoftDeletes, isFullyDeleted, softUpdateDocument; symptoms such as "undelete lost", "duplicate live documents", "100% deleted"), plus manual gh search phrasings with comment matchingno report; closest: #8398 (open, related family), #9337 (LUCENE-8290, the write-time design), #9345 and #9344 (the null reset and the API; no discussion of this), #9280 (origin of the stale javadoc), #9431 (an older, fixed PendingSoftDeletes assertion bug), #11428 (open redesign idea), #16900 (a different bug in the same area)
Open PRs2026-10-25the 30 of 412 open PRs touching the relevant index files, changed lines grepped for getLiveDocs, getHardLiveDocs, onDocValuesUpdate, applySoftDeletes, isFullyDeleted, tryUpdateDocValue, addDVUpdate, undelete, acceptDocs and similarnone changes this mechanism (#13398 is an early exit relying on the same count; #12557, #16571, #16847 and others are optimizations or refactors)
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-2556 queries, plus direct full-text searches for undelete, un-delete, tryUpdateDocValue(s), softUpdateDocument, PendingSoftDeletes, getHardLiveDocsnotifications, CI mails and unrelated user questions only
JIRA archive (LUCENE)2026-10-2514 queries, plus text ~ undelete (16 issues), tryUpdateDocValue(s), softUpdateDocument, "soft deletes" AND "fully deleted"the 2018 design and accounting issues above; no report
Commits, downstream code, web2026-10-256 commit queries and git log -i -E --grep over the full history (44 commits), 5 downstream queries (Elasticsearch, OpenSearch, Solr), 12 web queriesno fix on main; no downstream callers of tryUpdateDocValue; only generic web pages
Study searches2026-10-22gh search issues and PRs, git log, web (verifier README section 4)the same related items, no report

History

Introduced by LUCENE-8298 (#9345, 2018, first released in 7.4.0), which documented un-delete through tryUpdateDocValue without changing when live docs change; the C2 form goes back to LUCENE-8233. All 67 releases from 7.4.0 to 10.5.2 and main are affected, pinned by commit-level runs; 7.3.1 lacks the API.

Versions tested12 rows

Introduced by LUCENE-8298 "Allow DocValues updates to reset a value" (#9345, @s1monw; JIRA-era patch), committed 2018-05-09 as 0c65af048b3 on the 7.x line (first released in 7.4.0) and a3c86373e47 on master. It let tryUpdateDocValue reset the soft-deletes field and documented that as un-delete, without changing when live bits change. The C2 form (no un-delete) is older: it is present from LUCENE-8233, which added soft deletes (2018-04-04), and is not caused by LUCENE-8290 (it fails just before and just after the 7.x backport of that change). The release runs used assertions on (7.4.0 also off); releases before 9.1 ran the pre-9.1 copy of the test. Every release since 7.4.0 is affected, and main is not fixed.

Versions and branches executed, with results
Version or commitVerdictResult
7.3.1 API absentthe test does not compile: setSoftDeletesField, SoftDeletesDirectoryReaderWrapper, softUpdateDocument and NumericDocValuesField(String, Long) do not exist
7.4.0 first affected4 of 5 fail (also with -da)
7.5.0-7.7.3 (6 releases) affected4 of 5 fail
8.0.0-8.11.4 (27 releases) affected4 of 5 fail
9.0.0-9.12.3 (21 releases) affected4 of 5 fail
10.0.0-10.5.2 (12 releases) affected4 of 5 fail; 10.5.2 with -ea and -da
main ea57360912ed affected4 of 5 fail with -ea and -da, 6 seeds; the study's 15-test repro: 10 of 15 fail
7.x 51fff3c7095 / master cf568904005 (parents of LUCENE-8233) API absentdo not compile (no setSoftDeletesField)
7.x 02fa5a0b059 / master ecc17f90233 (LUCENE-8233) C2 onlythe adapted C2 test fails (2 vs 1); the un-delete tests cannot be written yet
7.x 52e72467bb2 / master 7873cf845e1 (parents of LUCENE-8298) C2 onlythe un-delete tests do not compile on either line (no null value yet); the C2 test, run on its own at 7.x 52e72467bb2, fails (2 vs 1)
7.x 0c65af048b3 / master a3c86373e47 (LUCENE-8298) commit-levelK1, K1c, K2 and C2 fail, the control passes
option B on ea57360912ed fixed except C2K1, K1c, K2 pass, C2 fails; study repro 15/15 pass

Independent checks

Study verifiers voted "new bug"; a hunt re-verification and an adversarial cross-model review with its own repro confirmed it. Three checks of this doc (a fact check that re-ran the repro, a cross-model fact check and a diagram check) confirmed the bug and its numbers; every error they found in the text and the diagrams was fixed.

Checks and verdicts391 words

The hunt re-ran or re-read the claims on this page on ea57360912ed, except where a source names the study. Then three checks reviewed the text and the diagrams.

Each independent check and its verdict
CheckVerdictWhat it found
Study verifiers K: new bugmodel-based testing verifier, Alloy verifier, TLA+ verifier, Dafny verifier, P-model verifier, JBMC verifier and JML verifier each reproduced or modelled K and voted new. C2 was merged in by adjudication: 4 new / 2 intended votes on a related, term-based un-delete form; the test's soft=1-then-soft=2 form was added afterwards as evidence.
Public-list rerun reproducesThe study repro on main b83101cdd2: Tests run 15, Failures 10.
Hunt re-verification realMinimal 5-test JUnit fails 4 of 5 on main with -ea and -da, 6 seeds and 10 iterations, on 10.5.2 and 9.12.3; the refresh control passes.
Cross-model refute review real, high confidenceOpenAI GPT-5.6 wrote its own test with documents added through softUpdateDocument and SoftDeletesRetentionMergePolicy, reproduced K1 with -ea and -da, rated the intended-behaviour argument moderate, and did not adjudicate K1c, K2 or C2.
History since 7.4.0All 67 releases 7.4.0-10.5.2 affected; the un-delete forms pinned to LUCENE-8298 by commit-level runs; C2 present from LUCENE-8233.
Prior art new183 tool queries plus manual searches; no report, no fix, no open PR.
Fix validation option B validatedPasses the un-delete tests, the study's 15-test repro, the reviewer's repro, the edge test, 22 targeted suites with -ea and -da, the index test package and :lucene:core:check; C2 unchanged.
Line-321 diagnostic not a fixRe-checked here: getHardLiveDocs() at FrozenBufferedUpdates.java:321 breaks testReuseUnchangedLeafReader, as the study's JML verifier verifier reported.
Security screen out of scope of SECURITY.mdDownstream-relevant, out of scope of SECURITY.md, public.
Independent fact check sound, 7 text fixesRe-ran the repro 9 ways (Gradle and harness, -ea and -da, 7.4.0 to main), a probe of its own and option B; found 1 medium and 6 low text errors and 3 missing caveats, all fixed.
Cross-model fact check sound, 8 text fixesOpenAI GPT-5.6, read-only: the bug and mechanism hold; 8 factual errors (1 high: option B and running merges), 8 overclaims and 8 missing caveats, all fixed in the text.
Diagram check 2 fixes, re-renderedEvery value on screen matched the code and the runs.

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

Finding record

LFV-010, snapshot of 2026-11-06

Registry title
A try* un-delete or pending soft-delete change is ignored or lost by later term-based operations
Area
index / soft deletes
Priority
Low, rank 261: Needs the opt-in soft-deletes field plus an expert try* un-delete followed by term operations before a refresh, a pattern the IndexWriterConfig javadoc says has no API support.
Impact class
data corruption
Impact note
Users of the opt-in soft-deletes field who un-delete with try* or change the soft field by term and then issue term-based operations; the outcome depends on whether a flush, NRT open/close or a small RAM buffer intervened; released.
Summary
With a soft-deletes field, you revive a document with tryUpdateDocValue (or change its soft field by term) and then issue term-based deletes or updates. You expect the later operations to see the revived or newly soft-deleted state. Instead term-based ops resolve against live docs that are only materialized when doc-values updates are written, so the revived document is skipped (duplicate live keys) or lost when its segment looks 100% deleted.
Introduced
LUCENE-8298 (#9345; 0c65af048b3 on branch_7x, a3c86373e47 on master) [exec: commit-level runs]; the C2 form dates from LUCENE-8233 (soft deletes, 2018-04-04) (2018-05-09)
First affected version
7.4.0 [exec]
Affected versions
7.4.0 to 10.5.2 (all 67 releases) and main [exec]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
Found by
non-formal baseline: randomized/MBT; also reached by TLA+/TLC, Alloy, P, Dafny, JBMC, KeY/OpenJML
Upstream and prior reports
none found (study searches 2026-10-22; public-list check 2026-10-20..23); related #8398
Lucene commit tested
main ea57360912ed (2026-10-25/26), assertions on and off, 6 seeds and 10 iterations; the Maven Central jars of all 67 releases from 7.4.0 to 10.5.2 (assertions on; 7.4.0 also off) and of 7.3.1; commit-level runs at LUCENE-8233, LUCENE-8253 and LUCENE-8298 and their parents on the 7.x and master lines, and at the 7.x backport of LUCENE-8290 and its parent (the C2 test only, except at LUCENE-8298 and its parents); fix option B in a scratch worktree at ea57360912ed (the repro, the study's 15-test repro, the reviewer's repro, an edge-case test, 22 targeted suites with -ea and -da, the org.apache.lucene.index test package and :lucene:core:check).

Lucene and upstream sources

Finding record
  • SECURITY.md:28 (threat model)
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:117-118, 596, 608, 1688-1696, 1707-1720, 1775, 3819-3827, 6726-6733, 6750-6752; DocumentsWriterDeleteQueue.java:616-629 (the reserved sequence numbers)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:153-164, 429; PendingSoftDeletes.java:126-149, 151-167; PendingDeletes.java:108-112, 219-221, 243-246; FrozenBufferedUpdates.java:321, 327, 341
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:117-118, 131-133, 1688-1696, 1707-1720, 1918-1919, 2060-2061; IndexWriterConfig.java:543-548, 557-559
  • Lucene main ea57360912ed: lucene/CHANGES.txt:7384-7393 (LUCENE-8297, LUCENE-8298)
  • Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/index/TestSoftDeletesRetentionMergePolicy.java:595-625, 696-745, 781-792; TestMixedDocValuesUpdates.java:589-599; TestSoftDeletesDirectoryReaderWrapper.java:94-165
Suggested fix
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:608, 4531-4540, 5310; ReadersAndUpdates.java:416-429, 870-882; DocValuesFieldUpdates.java:153-168; FrozenBufferedUpdates.java:351-357; SoftDeletesRetentionMergePolicy.java:154-183

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 gap between the queue of unwritten doc-values changes and the live docs and deleted count that later term-based operations consult, for the two main cases: the duplicate key (K1c) and the lost document (K2). The still is the summary card. An independent check compared every value, API line and order of events with the code.

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.