All findings · Lucene finding · LFV-008 · soft-delete-reset-revives-hard-deleted

Removing a document's soft-delete value brings a hard-deleted document back

With a soft-deletes field on, removing the soft-delete value of a hard-deleted document makes it live again: the repro's live documents are [a, c] instead of [c], with no error. A merge copies it into the merged segment with CheckIndex clean; every release since 7.4.0 has it.

Summary

Priority
Medium, rank 76The most severe soft-delete outcome (a hard-deleted document comes back, softDelCount reaches -1 and can corrupt commits and merges), but it needs an unsupported un-delete plus a hard delete of the same document in one window.
Impact
data corruption
Since
7.4.0 (LUCENE-8298, 2018)introduced 2018-05-07 · about 8 years
Affected
7.4.0 to 10.5.2 (all 67 releases) and main
Status
documented
Found by
model-based randomized testing

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

One segment, three documents, one soft delete, one hard delete, and a null soft update of the hard-deleted document.

Summary card titled "Removing a document's soft-delete value brings a hard-deleted document back" (Apache Lucene IndexWriter with setSoftDeletesField("soft_delete"); docs a, b, c are doc 0, 1, 2 of one segment). A panel lists four numbered calls: 1, w.updateDocValues(new Term("id", "b"), new NumericDocValuesField("soft_delete", 1)): soft-delete b; reader r opens: b is hidden, soft-delete count 1. 2, w.deleteDocuments(new Term("id", "a")): hard-delete a, buffered until the next reader opens. 3, w.tryUpdateDocValue(r, 0, new NumericDocValuesField("soft_delete", (Long) null)): remove a's soft value; a has none, so this should change nothing. 4, DirectoryReader.open(w): applies the delete of a, then the update; applySoftDeletes sets a's live bit again and the count drops from 1 to 0. Bottom left, a table "segment _0 after step 4" for a, b, c: soft_delete value none, 1, none; hard deletes deleted, live, live; live docs live (marked with a cross, "a is live again"), deleted, live; soft-delete count 0 (expected 1: b still has a soft value). Bottom right, with a check mark: Expected: live docs [c]; a stays deleted. With a cross: Actual: live docs [a, c]; searches find a again; count(id:a) = 1 in the new reader, and no error. Three notes: a merge in the same session copies a into the new segment, and the commit keeps a and passes CheckIndex; a commit instead fails CheckIndex: "actual soft deletes: 1 but expected: 0"; with no other soft-deleted doc the count reaches -1: a reader open throws, and retrying or committing fails too. Affected: Lucene 7.4.0 to 10.5.2, and main.
The summary card: the four calls, segment _0 after the refresh (soft value, hard deletes, live docs, soft-delete count) and what goes wrong. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Turn on soft deletes (setSoftDeletesField("soft_delete")) and commit a, b, c (doc 0, 1, 2) in one segment. Soft-delete b with updateDocValues(id:b, soft_delete=1) and open an NRT reader r: live [a, c], soft-delete count 1.
  2. Hard-delete a with deleteDocuments(id:a), then remove a's soft value with tryUpdateDocValue(r, 0, soft_delete=null). a never had a soft value, so this should change nothing.
  3. You expect the next NRT reader to show live [c]: the setSoftDeletesField javadoc says hard-deleted documents are not affected by soft values.
  4. Instead it shows [a, c], a search for id:a finds 1 hit, and the soft-delete count drops to 0 although b is still soft-deleted. The refresh applies the hard delete first, then the null update, and applySoftDeletes sets a's live bit again without checking the hard deletes.
  5. No error. A merge in the same writer session copies a into the merged segment and CheckIndex passes; with no other soft-deleted document the count reaches -1 and the writer throws.

Who is affected

Applications with the opt-in soft-deletes field that clear a document's soft value, for example "restore, then purge": un-delete a soft-deleted document, then hard-delete it before the next refresh. Three documents and the public API are enough; every release from 7.4.0 to 10.5.2 and main is affected. A code search found no tryUpdateDocValue callers in Elasticsearch or OpenSearch.

Mechanism, terms and numbers745 words

Turn on soft deletes, hard-delete a document, then remove that document's soft-delete value (a null doc-values update, the documented way to un-delete a soft-deleted document): the hard-deleted document is live again. In the repro, with b soft-deleted, hard-deleting a and removing a's soft value leaves the live documents [a, c] instead of [c], with no error. A merge in the same writer session copies it into the merged segment, which no longer records the hard delete, and CheckIndex does not flag the commit. When no other document in the segment is soft-deleted, the soft-delete count goes to -1 instead: with assertions on the writer closes on an AssertionError, and with assertions off that refresh throws, and so did every later refresh, commit and close in the tested sequence. Soft deletes are opt-in, three documents are enough, and every release since 7.4.0 has it.

What you do

Turn on soft deletes with IndexWriterConfig#setSoftDeletesField("soft_delete"). A document can then be deleted in two ways:

  • Hard delete: deleteDocuments(id:a). The document is gone; merges drop it.
  • Soft delete: give the document a value in the soft_delete doc-values field, for example updateDocValues(id:b, soft_delete=1). Readers skip it, but a retention merge policy can keep it around.

And there is one way back: remove the soft value again with a null doc-values update by doc ID, tryUpdateDocValue(reader, doc, soft_delete=null), which applies "even if the document is marked as deleted"; its javadoc says this "can be used to un-delete a soft-deleted document". A null update by term, updateDocValues(term, soft_delete=null), removes values too, but it is resolved against the current live docs, so it skips documents whose delete has already been applied.

The rules: the setSoftDeletesField javadoc says "Hard-deleted documents won't be considered as soft-deleted even if they have a value in the soft-deletes field", and the null update is documented as the way to bring back a soft-deleted document. Nothing allows it to bring back a hard-deleted one.

A tiny example

Three documents a, b, c (doc 0, 1, 2) in one committed segment, NoMergePolicy (c stays live, so the segment never becomes fully deleted and dropped):

stepcalllive docs you expectsoft-delete count
1soft-delete b: updateDocValues(id:b, soft_delete=1)a, c1
2open an NRT reader r; hard-delete a: deleteDocuments(id:a)c1
3remove a's soft value: tryUpdateDocValue(r, 0, soft_delete=null) (a never had one)c1
4open a fresh NRT readerc1

What you get: live [a, c]. a is back, and a search for id:a finds it. The writer's soft-delete count says 0, although b is still soft-deleted. Nothing fails.

The documented un-delete does the same: soft-delete b and d, un-delete b, then hard-delete b before the next refresh. You expect live [a, c] and get [a, b, c]: a "restore, then purge" brings the purged document back.

Why: a clear bit is not always a soft delete

Each segment keeps two sets of live bits, the hard live docs and the combined live docs (hard and soft deletes together, which readers and merges use), plus a soft-delete count of documents that are soft-deleted but not hard-deleted.

  1. At the next refresh the writer first applies the buffered hard delete of a (IndexWriter.java:586-596). a is cleared in both sets; the count stays 1 (that is b).
  2. Then it writes the pending doc-values updates (IndexWriter.java:608) and applies a's null update to the live docs (ReadersAndUpdates.java:429 -> PendingSoftDeletes.onDocValuesUpdate, PendingSoftDeletes.java:151-158).
  3. The branch for a removed value reasons "the bit is clear, so this was a soft delete: set it, and count one soft delete less": if (bits.getAndSet(docID) == false) newDeletes--; (PendingSoftDeletes.java:143). It never looks at the hard live docs.
  4. So a's combined bit is set again, which makes a live for NRT readers and for merges, and the count drops from 1 to 0.

A merge in the same writer session copies a into the new segment, which no longer records the hard delete, and CheckIndex does not flag it. If no other document in the segment is soft-deleted, the count goes to -1 instead, and the writer starts throwing. Timing matters: if a refresh happens between the null update and the hard delete, the delete sticks, and a term-based null update that is in the same buffered packet as the hard delete correctly skips the deleted document.

Exposure and likelihood496 words
  • Who is hit. Applications with a soft-deletes field that issue a null update of that field for a document that is hard-deleted, or gets hard-deleted, before the update is written (the next NRT refresh, commit or merge): by doc ID with tryUpdateDocValue, or by term while the document is still live. Natural ways to get there: restore, then purge (un-delete a soft-deleted document, then hard-delete it before the next refresh); clearing the soft value by term as a "make sure it is live" step and hard-deleting the document after a flush; or one code path un-deleting by doc ID while another hard-deletes by term.
  • Opt-in, otherwise ordinary. Soft deletes are off by default. With them on, nothing else is special: three or four documents and public API only; the same result with every seed tried (4, on the earlier 7-test copy) and with the default codec forced.
  • Silent and permanent with a retention policy and default merging. With SoftDeletesRetentionMergePolicy over the default TieredMergePolicy and the default ConcurrentMergeScheduler: after forceMerge(1) the commit has live [a, c, x] instead of [c, x]; with no forceMerge and 30 more one-document commits, the commit has 7 segments and a is live. CheckIndex is clean both times.
  • Loud when there is no slack. If the segment has no other soft-deleted document, the count goes to -1. With assertions on that is an AssertionError ("illegal pending delete count: -1"), a tragic event that closes the writer. With assertions off, that reader open throws IllegalArgumentException: invalid softDelCount=-1; the exception is not tragic, but in the tested sequence the next open, a commit and a close threw it too, and rollback() then discarded the work since the last commit (other repairs were not tried). With the plain default merge policy the tiny repro index lands here: when the NRT reader was opened, the small segments had been merged and b reclaimed (one segment, 0 deletions).
  • A commit without a merge keeps the hard delete on disk but records a soft-delete count that is too low; CheckIndex reports actual soft deletes: 1 but expected: 0 (and fails on it in every release run from 8.11.0 on).
  • A corrupt merged segment in a longer, heavily configured sequence found by the test harness (index sort, SoftDeletesRetentionMergePolicy over a two-segments-per-tier TieredMergePolicy, SerialMergeScheduler, addIndexes, a term-based null update of two live documents and a delete-by-query that hard-deletes both): the merge run by commit() fails an assertion (numMerged=4 vs mergeState.segmentInfo.maxDoc()=6) with -ea; with -da the commit returns and reading stored fields from a new NRT reader throws IndexOutOfBoundsException (the committed index was not reopened or checked). The same fix removes both. It is secondary and should not lead an issue.
  • Reach. A code search found no tryUpdateDocValue callers and only test-file callers of updateDocValues in Elasticsearch and OpenSearch, so the known exposure is custom applications (the search says nothing about private code). Every release from 7.4.0 to 10.5.2 is affected.

Minimal repro

The test shows that with a soft-deletes field (b soft-deleted), hard-deleting doc a and then removing a's soft value with tryUpdateDocValue(reader, 0, null) makes a live again.

TestSoftValueRemovalRevivesHardDeletedDoc.java (JUnit test)52 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;

public class TestSoftValueRemovalRevivesHardDeletedDoc extends LuceneTestCase {

  public void testHardDeletedDocStaysDeleted() throws Exception {
    IndexWriterConfig iwc =
        new IndexWriterConfig().setSoftDeletesField("soft").setMergePolicy(NoMergePolicy.INSTANCE);
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      for (String id : new String[] {"a", "b", "c"}) {
        Document doc = new Document();
        doc.add(new StringField("id", id, Field.Store.NO));
        w.addDocument(doc);
      }
      w.updateDocValues(new Term("id", "b"), new NumericDocValuesField("soft", 1));
      try (DirectoryReader r = DirectoryReader.open(w)) {
        w.deleteDocuments(new Term("id", "a"));
        w.tryUpdateDocValue(r, 0, new NumericDocValuesField("soft", (Long) null));
      }
      try (DirectoryReader r = DirectoryReader.open(w)) {
        // a was hard-deleted, then its (absent) soft value was removed: expected 0 hits, actual 1
        assertEquals(0, new IndexSearcher(r).count(new TermQuery(new Term("id", "a"))));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-008/, 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.TestSoftValueRemovalRevivesHardDeletedDoc
Expected vs actual
Expected: count(id:a) is 0 on the next NRT reader (a hard-deleted doc stays deleted, IndexWriterConfig#setSoftDeletesField). Actual (-ea and -da): expected:<0> but was:<1>.

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

More files, environment, full output34 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<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.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.index.TestSoftValueRemovalRevivesHardDeletedDoc.testHardDeletedDocStaysDeleted(TestSoftValueRemovalRevivesHardDeletedDoc.java:48)

Why it looks unintended

  • The javadoc says hard-deleted documents "won't be considered as soft-deleted"; the null update is documented only for un-deleting soft-deleted documents.
  • Lucene's own checks reject it: the soft-delete count goes wrong (to -1, which throws) and CheckIndex fails the commit.
  • It depends on buffer timing: a refresh in between makes the delete stick. The 2018 review raised the hazard; nobody answered.
The full argument333 words
  • The contract. IndexWriterConfig#setSoftDeletesField (IndexWriterConfig.java:561-563): "If soft-deletes are enabled, documents can still be hard-deleted. Hard-deleted documents won't be considered as soft-deleted even if they have a value in the soft-deletes field." The tryUpdateDocValue javadoc (IndexWriter.java:1712-1715) and CHANGES.txt:7390-7393 (LUCENE-8298, 7.4.0) describe a null value as the way to un-delete a soft-deleted document.
  • The count. LUCENE-8341 (#9388) defines the recorded count as "the number of documents that are soft deletes but not hard deleted". SegmentCommitInfo.setSoftDelCount rejects negative values (SegmentCommitInfo.java:384-388) and CheckIndex rejects a mismatch (CheckIndex.java:4945-4947). The hard-delete path keeps this invariant (PendingSoftDeletes.delete, :50-66, takes a soft-deleted document out of the count at :60); the value-removed branch does not.
  • Upstream tests. TestSoftDeletesRetentionMergePolicy.testMergeSoftDeleteAndHardDelete says "hard-delete should supersede the soft-delete" (:663); testUndeleteDocument (:595) un-deletes only a soft-deleted document. None of the tests added with LUCENE-8298 calls a delete API, so the combination was never tested.
  • Foreseen in review. In the LUCENE-8298 review (#9345, 2018-05-09) a committer (@jpountz) wrote that "the ability to un-hard-delete is a bit scary to me because of the documents that got marked as deleted because they failed indexing". The patch was revised and approved; the merged branch still has no hard-delete check.
  • Precedent. LUCENE-8381 (#9428; JIRA fix version 7.4.1, which was never published, so first released in 7.5.0): hard-deleted documents were counted as soft-deleted in merges, and this was fixed as a bug. The reporter first wrote that SoftDeletesRetentionMergePolicy "brings to live documents that soft-deleted then hard-deleted"; a maintainer replied that "it won't bring a deleted document back to live it will just loose the hard delete bit". Same invariant (hard deletes win), different code, and not a visible revival.
  • Timing. If an NRT reader is opened between the null update and the hard delete, the hard delete sticks (study control 3). A documented behaviour would not depend on hidden buffer state. And in two of the failing tests the hard delete is the later operation, yet it loses.
The devil's-advocate check331 words

Strongest case for "intended": tryUpdateDocValue is an Expert API, and its javadoc says it "will apply the field update even if the document is marked as deleted"; read broadly, "deleted" includes hard deletes and "apply" includes making the document live. In the core case the null update is also the later operation (higher sequence number), so "the later change wins" can be argued there. IndexWriterConfig.java:557-559 even says "Currently there is no API support to un-delete a soft-deleted document", so null soft updates could be called unsupported. And IndexWriterConfig.java:547-549 says that with soft deletes "documents must be indexed via" softUpdateDocument, while the repro uses addDocument.

Why it fails: the same javadoc sentence names its purpose, un-deleting a soft-deleted document, and setSoftDeletesField says soft values do not affect hard-deleted documents; "apply the field update" means writing the value, not undoing a hard delete. The sequence-number reading covers one order only: in testUndeleteThenHardDelete and testTermApiOnly the hard delete is the later operation and still loses, although sequence numbers express "the effective order in which each change was applied" (IndexWriter.java:117-118). The term API itself skips deleted documents (it resolves terms against the live docs), so the result depends on buffer timing. softUpdateDocument with a term that matches nothing just adds the document, upstream's soft-delete tests index with addDocument too, and IndexWriterConfig.java:562 allows hard deletes. The "no API support" sentence is stale against LUCENE-8298, and the control test, the documented un-delete, passes. Even if reviving were allowed, a soft-delete count of -1, exceptions on the following opens, commits and closes, and a commit that CheckIndex rejects are broken invariants, not API behaviour. testTermApiOnly removes the soft value of a document that never had one (absent before and after) and still wedges the writer. An independent reviewer from another model family (OpenAI GPT-5.6) built a similar argument and rated it weak; a second cross-model check rated it weak overall and moderately plausible only for the core case. Verdict: not intended, a defect.

Suggested fix

Recommended A: skip hard-deleted documents in the value-removed branch

Option A: in PendingSoftDeletes.applySoftDeletes, set the live bit for a removed soft value only if the document is not hard-deleted (onDocValuesUpdate passes hardDeletes.getLiveDocs(); +12/-2 in one file). The repro passes 8/8 with assertions on and off, and all 8,873 :lucene:core tests pass. A check at call time is not enough.

static int applySoftDeletes(DocIdSetIterator iterator, FixedBitSet bits, Bits hardLiveDocs) ... {
  ...
  } else {
    if ((hardLiveDocs == null || hardLiveDocs.get(docID)) && bits.getAndSet(docID) == false) {
      newDeletes--;
    }
  }

// onDocValuesUpdate
FixedBitSet mutableBits = getMutableBits();
pendingDeleteCount += applySoftDeletes(iterator, mutableBits, hardDeletes.getLiveDocs());
All options and trade-offs4 options ยท 545 words

The value-removed branch treats every clear live bit as a soft delete. The fix makes it skip documents that are hard-deleted at the moment the update is applied. A check at call time is not enough, because the hard delete may still be buffered, or not yet issued, then. One option was built and validated on a scratch worktree at ea57360912ed (removed afterwards); the others are untested.

A: skip hard-deleted documents in the value-removed branch (validated by the checks) (recommended)

onDocValuesUpdate passes the hard live docs to applySoftDeletes, and the branch sets the bit and lowers the count only for a document that is not hard-deleted. +12/-2 lines, PendingSoftDeletes.java only. It is the study verifier's patch, unchanged.

static int applySoftDeletes(DocIdSetIterator iterator, FixedBitSet bits, Bits hardLiveDocs) ... {
  ...
  } else {
    if ((hardLiveDocs == null || hardLiveDocs.get(docID)) && bits.getAndSet(docID) == false) {
      newDeletes--;
    }
  }

// onDocValuesUpdate
FixedBitSet mutableBits = getMutableBits();
pendingDeleteCount += applySoftDeletes(iterator, mutableBits, hardDeletes.getLiveDocs());

Trade-offs

  • A null soft update of a hard-deleted document still removes the doc value, but no longer changes liveness or the count. The documented un-delete of a soft-deleted document is unchanged (the control passes).
  • Not measured: hardDeletes.getLiveDocs() hands out a read-only snapshot and drops the writable copy (PendingDeletes.java:107-112). If a writable hard live-docs copy exists at that moment, the next hard delete in that segment copies the bitset once (maxDoc bits, PendingDeletes.java:63-81); otherwise nothing extra is copied. An accessor without that side effect would avoid it.
  • The two-argument applySoftDeletes stays for its other two callers, which pass plain doc-ID iterators and only ever clear bits.
  • It does not change the related bugs LFV-009 and LFV-010: their repros fail exactly as before with it.

What the checks validated

  • The repro: 8/8 pass with assertions on and off; with the fix stashed, 7 of 8 fail.
  • 21 nearby suites in :lucene:core (soft deletes, pending deletes, doc-values updates, TestIndexWriter, TestIndexWriterReader, TestIndexWriterDelete, TestStressNRT, TestCheckIndex, TestAddIndexes and more): 405 tests, 0 failures; with -Ptests.asserts=false: 406 tests, 0 failures.
  • All of :lucene:core:test: 747 test classes, 8,873 tests, 322 skipped, 0 failures.
  • :lucene:core:check -x test passes (google-java-format, ecjLint, forbiddenApis); tidy did not change the patch.
  • With the patched class shadowed in the standalone harness: the merge-policy probe (4/4), the harness-found sorted-merge case, and the cross-model reviewer's own test pass.

B: the same guard as a required parameter (untested)

Give applySoftDeletes a Bits hardLiveDocs parameter and pass null from the other two callers, instead of adding an overload.

Trade-offs

Same behaviour as A; touches two files, and no overload hides the difference.

C: reject the update at the API (not sufficient)

Make tryUpdateDocValue return -1, or drop the reset, when the target is hard-deleted.

Trade-offs

Misses the failing cases: when the try call runs, the hard delete is still buffered (it is applied later, during IndexWriter.java:586-596, before the update is written at :608) or not issued yet (testUndeleteThenHardDelete), and testTermApiOnly never calls try*.

D: document it (untested)

State that a null soft update can revive hard-deleted documents.

Trade-offs

Contradicts IndexWriterConfig.java:561-563 and leaves the negative count, the wedged writer and the CheckIndex failure. Separately, the stale "no API support to un-delete" sentence (IndexWriterConfig.java:557-559) could be fixed with any option.

Downstream note

Data exposure: a document the application hard-deleted (to purge it, say) is visible to searches again, and a merge can carry it into the merged segment with CheckIndex clean. Only the application's own API calls trigger it (no attacker input reaches Lucene), so it is out of scope of SECURITY.md: an ordinary public bug.

How it was found

Model-based randomized testing: an AI-written harness ran random IndexWriter operation sequences against a reference model, and Lucene's own -ea assertions and CheckIndex flagged this 4.2 s into a smoke run. The prompt scoped it to IndexWriter/NRT and listed soft deletes and tryUpdateDocValue among many operations, but gave no hint of this bug.

The method in full201 words

Method: model-based randomized testing (the study's non-formal baseline), with later re-finds by formal tools that were told the bug class.

  • A 2026-10-22 study of Lucene by AI tools covered the IndexWriter/NRT layer because an earlier bug there (now #16900) was its starting point. The experiment's prompt scoped it to IndexWriter and NRT readers and listed a broad operation alphabet that named soft deletes, tryUpdateDocValue and doc-values updates of the soft-deletes field among many others; its only bug hint was a file name pointing at the earlier bug's class. It was not told about this bug or its symptom.
  • An AI tool wrote a model-based testing harness: random IndexWriter operation sequences against an executable reference model. Lucene's own -ea assertions plus CheckIndex flagged this bug 4.2 s into a smoke run, and 51.3 s and 21.7 s into the two base hunt.
  • TLA+, Alloy, P, Dafny, JBMC/JPF and OpenJML models re-found it later, with the bug class given as a hint.
  • Independent AI verifiers reproduced it and voted "new bug". This hunt re-verified it on ea57360912ed, added the merge and retention-policy cases, ran the release history and the prior-art search, had it reviewed adversarially by OpenAI GPT-5.6, and validated the fix.

Prior reports, history and checks

Prior reports

No report found: 210 queries over GitHub issues and PRs, mailing lists, JIRA, commits, downstream code and the web (2026-10-25), 33 threads read; no open PR changes the code. The hazard was raised once, in the 2018 review of the introducing change, and not followed up.

Searches5 searches

No report of this bug was found, and upstream has not fixed it. The hazard was foreseen once, in the 2018 review of the change that introduced it, and not followed up; the prior-art pass therefore labels it partially_known (the registry lists it as new).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene), with migrated JIRA issues2026-10-2560 queries from the scripted prior-report search plus 41 manual gh searches: class and method names (PendingSoftDeletes, applySoftDeletes, onDocValuesUpdate, tryUpdateDocValue(s), setSoftDelCount, getHardLiveDocs), the error messages ("illegal pending delete count", "invalid softDelCount", "actual soft deletes"), and phrasings such as undelete, un-hard-delete, "deleted documents come back"no report; closest: #9345 (LUCENE-8298, the change, with the review comment), #9428 (LUCENE-8381, precedent: hard deletes counted as soft deletes in merges, first released in 7.5.0), #9388 (LUCENE-8341, the count definition), #9504 and #9431 (earlier soft-count bugs in the same file, different causes), #9344 (the try API)
Open PRs2026-10-25all 412 open PRs with their changed files; the 11 touching PendingSoftDeletes, PendingDeletes, ReadersAndUpdates, SegmentCommitInfo or the soft-delete classes had their diffs greppednone changes the value-removed branch
Mailing lists (dev@, java-user@, issues@, commits@) and the JIRA archive2026-10-2572 list queries and 15 JIRA queriesonly notifications for the items above and one unrelated usage question
Commits, downstream code, web2026-10-256 commit queries, 5 downstream (Elasticsearch, OpenSearch, Solr), 6 code searches, 9 web queriesno fix on main; no downstream report; elastic/elasticsearch#32269 (2018) is the only related page: why hard deletes must stay hard
Study searches2026-10-22gh issue and PR search, downstream repos, web (verifier README section 4)the same related items, no report

History

Introduced by LUCENE-8298 (#9345, 2018, first released in 7.4.0) and pinned by commit-level runs. All 67 releases from 7.4.0 to 10.5.2 and main are affected; 7.3.1 has no soft deletes. Not fixed upstream.

Versions tested10 rows

Introduced by LUCENE-8298 "Allow DocValues updates to reset a value" (#9345, a migrated JIRA issue; by @s1monw, reviewed by @mikemccand and @jpountz), committed 2018-05-09 as 0c65af048b3 on the 7.x line (first released in 7.4.0) and a3c86373e47 on master (released from 8.0.0). It added the null value, the reset in updateDocValues / tryUpdateDocValue, and the value-removed branch. Since then the branch changed once (#13507, 2024, get + set rewritten as getAndSet, same behaviour). Every release since 7.4.0 is affected, and main is not fixed. The CheckIndex test passes before 8.11.0 only because CheckIndex then printed the soft-delete error without failing (LUCENE-9662 changed that; that the change reached the 8.x line in 8.11.0 is inferred from the release runs, because the local history clone has no 8.11 sources).

Versions and branches executed, with results
Version or commitVerdictResult
7.3.1 no soft deletesthe test does not compile: setSoftDeletesField and tryUpdateDocValue do not exist
7.4.0 first affected5 of 7 fail (also with -da); the control passes
7.5.0-8.10.1 (28 releases) affected5 of 7 fail; CheckIndex prints "actual soft deletes: 1 but expected: 0" but reports the index clean
8.11.0-10.5.2 (38 releases) affected6 of 7 fail; 8.11.4, 9.12.3 and 10.5.2 also with -da
10.5.2 (final 8-test file) affected7 of 8 fail with -ea and -da
main ea57360912ed affected7 of 8 fail with -ea and -da (gradle, seed DEADBEEF); the earlier 7-test copy also with 3 more seeds and the default codec forced
7.x 52e72467bb2 / master 7873cf845e1 (parents of LUCENE-8298) cannot expressno null value: the test stops in its own code before any Lucene call
7.x 0c65af048b3 / master a3c86373e47 (LUCENE-8298) commit-level5 of 7 fail, the control passes; the revival is silent there (the count checks came with LUCENE-8341, also in 7.4.0)
0c65af048b3 plus the hard-delete guard commit-level7 of 7 pass
fix A on ea57360912ed fixed8 of 8 pass; all 8,873 :lucene:core tests pass

Independent checks

Study verifiers, a hunt re-verification and an adversarial cross-model review confirm the bug. Three checks of this doc (an independent fact check, a cross-model fact check and a diagram check) found no error in the bug itself; every error they found in the text and the diagram was fixed.

Checks and verdicts487 words

Every claim on this page was re-run or re-read on ea57360912ed in this hunt; the study's checks came first. Three checks then reviewed this page, the brief and the diagrams.

Each independent check and its verdict
CheckVerdictWhat it found
Study verifiers new bugmodel-based testing verifier, Alloy verifier, Dafny verifier, JBMC verifier, JML verifier, TLA+ verifier and P-model verifier each reproduced or modelled it and voted new.
Public-list rerun reproducesThe study repro on main b83101cdd2: Tests run 9, Failures 5.
Hunt re-verification realThe 7-test candidate fails 6 of 7 on main and 10.5.2 with -ea and -da, 4 seeds and the default codec; new: a merge makes the revival permanent with CheckIndex clean.
Cross-model refute review real, high confidenceOpenAI GPT-5.6 wrote its own test (hard-deleted target searchable after the null update; a soft-only un-delete control passes), ran it with 3 seeds and -da, rated the intended-behaviour argument weak, and confirmed the fix on its test and 22 nearby tests. It did not re-run the term, merge, CheckIndex and negative-count variants.
Brief-time probes wider reachWith SoftDeletesRetentionMergePolicy and default merging the revived document reaches the commit (CheckIndex clean); with the plain default policy the count goes to -1; the restore-then-purge case revives silently; the harness's sorted-merge case still corrupts on ea57360912ed. The fix removes all of them.
History since 7.4.0All 67 releases 7.4.0-10.5.2 affected; pinned to LUCENE-8298 by commit-level runs.
Prior art no report; foreseen in review210 queries and 33 threads read; no report, no fix, no open PR. Labelled partially_known because the 2018 review comment anticipated the hazard.
Fix validation fix A validatedThe repro 8/8, 21 nearby suites, all of :lucene:core:test (8,873 tests) and :lucene:core:check -x test pass.
Security screen out of scope of SECURITY.mdDownstream-relevant (data exposure: deleted documents visible), out of scope of SECURITY.md, public route.
Independent fact check bug confirmed; 2 doc errors and 9 minor items fixedRe-ran the brief's gradle command (8 run, 7 fail with -ea and -da; 8/8 with fix A) and the standalone harness, checked every file:line, quote, commit, date and release count. Errors: a stale diagram alt text and an overstated determinism claim; both fixed, with 9 precision items.
Cross-model fact check real; 6 errors, 7 overclaims, 5 caveats fixedOpenAI GPT-5.6, read-only: the root cause, the counts, the release runs and the fix are right; it found 6 factual errors (4 medium, 2 low), 7 overclaims and 5 missing caveats, none of which changes the conclusion, and rated the intended-behaviour case weak. All were applied.
Diagram check 1 diagram error fixed and re-renderedChecked the summary card frame by frame against the code and the probe. One error: in step 1 the diagram drew b's soft value before reader r opened. Fixed (the update now waits in the delete queue), re-rendered and re-checked on 2026-10-26.

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

Finding record

LFV-008, snapshot of 2026-11-06

Registry title
Removing a document's soft-delete value brings a hard-deleted document back
Area
index / soft deletes
Priority
Medium, rank 76: The most severe soft-delete outcome (a hard-deleted document comes back, softDelCount reaches -1 and can corrupt commits and merges), but it needs an unsupported un-delete plus a hard delete of the same document in one window.
Impact class
data corruption
Impact note
Users of the opt-in soft-deletes field who clear the soft value of a hard-deleted document (tryUpdateDocValue or updateDocValues to null); ordinary scale; released since 7.4.0.
Summary
With a soft-deletes field configured, you hard-delete a document and then clear its soft-delete value. You expect the document to stay deleted. Instead PendingSoftDeletes.applySoftDeletes sets its live bit again, so it becomes visible; softDelCount can reach -1, which wedges the writer and gives a corrupt commit or merge.
Introduced
LUCENE-8298 (#9345, a3c86373e47d) (2018-05-07)
First affected version
7.4.0
Affected versions
7.4.0, 8.11.4, 9.12.3, 10.5.2, main [exec]; 7.3.1 has no soft deletes
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, JPF, KeY/OpenJML
Upstream and prior reports
none found (study read-only searches 2026-10-22; public-list check of issues/PRs created 2026-10-20..23)
Lucene commit tested
main ea57360912ed (2026-10-25/26), assertions on and off with seed DEADBEEF (an earlier 7-test copy also with 3 more seeds and with the default codec forced); the Maven Central jars of all 67 releases from 7.4.0 to 10.5.2 and of 7.3.1; the LUCENE-8298 commits and their parents (commit-level runs); a merge-policy probe (default TieredMergePolicy with and without SoftDeletesRetentionMergePolicy, default ConcurrentMergeScheduler); the fix in a scratch worktree at ea57360912ed (the repro, 21 nearby suites, all of :lucene:core:test, and :lucene:core:check -x test).

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/IndexWriterConfig.java:561-563 (contract); IndexWriter.java:1712-1715 (tryUpdateDocValue javadoc), 1770, 1775, 586-596, 608, 4124, 6750-6756; FrozenBufferedUpdates.java:321, 341
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/PendingSoftDeletes.java:36, 50-66, 126-149 (branch 142-146, line 143), 151-158; ReadersAndUpdates.java:429
  • Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/index/TestSoftDeletesRetentionMergePolicy.java:619, 781-789 (upstream un-delete: include-deletes reader + tryUpdateDocValue)
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:547-549, 557-559, 561-563; IndexWriter.java:117-118, 1707-1720, 1918-1921; FrozenBufferedUpdates.java:321, 341; SegmentCommitInfo.java:384-388; CheckIndex.java:4930-4947; PendingSoftDeletes.java:50-66
  • Lucene main ea57360912ed: lucene/CHANGES.txt:7390-7393 (LUCENE-8298)
  • Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/index/TestSoftDeletesRetentionMergePolicy.java:595, 610-613, 627, 640-644, 663
  • read-only gh issue view 9428 (LUCENE-8381 labels and comments, 2026-10-26); Lucene main ea57360912ed: lucene/CHANGES.txt:7290, 7298-7299
Suggested fix
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/PendingDeletes.java:63-81, 107-112; PendingSoftDeletes.java:83; SoftDeletesDirectoryReaderWrapper.java:142; IndexWriter.java:586-596, 608
Diagrams
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:586-596, 608, 2070; DocumentsWriter.java:145-150

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

Repro files

About the diagrams

The summary card (bug.png) lists the four calls and shows segment _0 after the refresh: the soft value, the hard deletes, the live docs that searches use, and the soft-delete count, with what was expected and what happened.

The term-based soft delete of b and the hard delete of a wait in the writer's delete queue and the null update waits in the segment's pending updates; the next reader open applies the delete first and then the update, which sets a's live bit again. Every state shown is one the probe and the repro printed on ea57360912ed.

An independent diagram check found one error in the first render: step 1 drew b's soft value before reader r opened. It was fixed (the update now waits in the delete queue until r opens), the step 4 code reference was made precise, and the diagram was re-rendered and its frames checked again on 2026-10-26.

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.