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.
- 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 readerr: x is hidden. - Un-delete x with
tryUpdateDocValue(r, doc 0, soft=null), as its javadoc describes: it succeeds with sequence number 7. Then replace x withsoftUpdateDocument(id:x, new x, soft=2)(8) and commit (10). - You expect 1 live x, the new one: the replace comes after the un-delete.
- 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.
- 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.
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.
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".
Two documents, id:x and id:y, no merges:
| step | call | returns | live copies of x you expect |
|---|---|---|---|
| 1 | add x and y, commit() | seq 1, 2; commit 4 | 1 |
| 2 | updateDocValues(id:x, soft_delete=1): soft delete | seq 5 | 0 |
| 3 | r = DirectoryReader.open(writer) | x is hidden in r | 0 |
| 4 | tryUpdateDocValue(r, docOf(x), soft_delete=null): un-delete | seq 7, success | 1 |
| 5 | updateDocValues(id:x, soft_delete=2): soft delete again | seq 8 | 0 |
| 6 | open 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 withsoftUpdateDocument, 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.
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.
- The un-delete only joins the queue.
tryUpdateDocValuehands its change to the segment withrld.addDVUpdate(updates)(IndexWriter.java:1775), which just appends it (ReadersAndUpdates.java:153-164). The bitmap still says "x deleted". Its twintryDeleteDocumentis different: it clears the live bit at once (IndexWriter.java:1694). - 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. - 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:327and:341). x looks deleted, so the step-5 soft delete, or the soft-delete half ofsoftUpdateDocument, skips it. Even the next reopen resolves the buffered step 5 (IndexWriter.java:596) before it writes the queued un-delete (:608). - 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 bysoftUpdateDocumenton 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
tryUpdateDocValueis an "Expert" public method. Default codec. K1 also fails underSoftDeletesRetentionMergePolicy; 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
tryUpdateDocValuecallers 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.
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.
/*
* 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)));
}
}
}
}
# 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>).
More files, environment, full output44 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: 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)- 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 calltryUpdateDocValue(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 atrysoft 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.
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 pendingtrysoft delete, so it can over-count reclaimable documents until the update is written (read, not tested). - Not covered (read, not tested): a
tryupdate that arrives while a merge of its segment is running is carried over to the merged segment with plainaddDVUpdate(IndexWriter.java:4531-4540), notaddTryDVUpdate, 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 fortryupdates 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 atrysoft-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. tryUpdateDocValueon a document that is also hard-deleted sets its live bit back: that is the separate bug LFV-008 (applySoftDeletesignores hard deletes), now hit inside thetrycall 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
tryupdate).
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
-eaand-da(stock: 10 of 15 fail). The reviewer's own repro: 2/2 pass. The edge test: 5/5 pass. :lucene:core:teston 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 testpasses (google-java-format, ecjLint, forbiddenApis) aftertidyre-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.
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 newtryupdate is not written (other eligible pending updates may be) and the window stays open (read, not tested).
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.
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.
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.
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).
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene), with migrated JIRA issues | 2026-10-25 | 90 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 matching | no 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 PRs | 2026-10-25 | the 30 of 412 open PRs touching the relevant index files, changed lines grepped for getLiveDocs, getHardLiveDocs, onDocValuesUpdate, applySoftDeletes, isFullyDeleted, tryUpdateDocValue, addDVUpdate, undelete, acceptDocs and similar | none 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-25 | 56 queries, plus direct full-text searches for undelete, un-delete, tryUpdateDocValue(s), softUpdateDocument, PendingSoftDeletes, getHardLiveDocs | notifications, CI mails and unrelated user questions only |
| JIRA archive (LUCENE) | 2026-10-25 | 14 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, web | 2026-10-25 | 6 commit queries and git log -i -E --grep over the full history (44 commits), 5 downstream queries (Elasticsearch, OpenSearch, Solr), 12 web queries | no fix on main; no downstream callers of tryUpdateDocValue; only generic web pages |
| Study searches | 2026-10-22 | gh search issues and PRs, git log, web (verifier README section 4) | the same related items, no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 7.3.1 | API absent | the test does not compile: setSoftDeletesField, SoftDeletesDirectoryReaderWrapper, softUpdateDocument and NumericDocValuesField(String, Long) do not exist |
| 7.4.0 | first affected | 4 of 5 fail (also with -da) |
| 7.5.0-7.7.3 (6 releases) | affected | 4 of 5 fail |
| 8.0.0-8.11.4 (27 releases) | affected | 4 of 5 fail |
| 9.0.0-9.12.3 (21 releases) | affected | 4 of 5 fail |
| 10.0.0-10.5.2 (12 releases) | affected | 4 of 5 fail; 10.5.2 with -ea and -da |
main ea57360912ed | affected | 4 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 absent | do not compile (no setSoftDeletesField) |
7.x 02fa5a0b059 / master ecc17f90233 (LUCENE-8233) | C2 only | the 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 only | the 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-level | K1, K1c, K2 and C2 fail, the control passes |
option B on ea57360912ed | fixed except C2 | K1, K1c, K2 pass, C2 fails; study repro 15/15 pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| Study verifiers | K: new bug | model-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 | reproduces | The study repro on main b83101cdd2: Tests run 15, Failures 10. |
| Hunt re-verification | real | Minimal 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 confidence | OpenAI 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.0 | All 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 | new | 183 tool queries plus manual searches; no report, no fix, no open PR. |
| Fix validation | option B validated | Passes 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 fix | Re-checked here: getHardLiveDocs() at FrozenBufferedUpdates.java:321 breaks testReuseUnchangedLeafReader, as the study's JML verifier verifier reported. |
| Security screen | out of scope of SECURITY.md | Downstream-relevant, out of scope of SECURITY.md, public. |
| Independent fact check | sound, 7 text fixes | Re-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 fixes | OpenAI 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-rendered | Every value on screen matched the code and the runs. |
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 515 KB
Finding record, Lucene and upstream sources, repro files and diagram notes595 words
- 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 atea57360912ed(the repro, the study's 15-test repro, the reviewer's repro, an edge-case test, 22 targeted suites with-eaand-da, theorg.apache.lucene.indextest package and:lucene:core:check).
- 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-559Lucene 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
- TestTryUndeleteThenSoftDelete.java (JUnit test):
repro/LFV-010/src/org/apache/lucene/index/TestTryUndeleteThenSoftDelete.java(62 lines)
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.
assets/LFV-010/bug.png(515 KB)
