Soft deletes keep a deleted document in its segment until a merge removes it, and tryUpdateDocValue with a null soft-deletes value is the documented way to restore it. Example: three documents, one soft-deleted, restored while a merge runs.
a (seq 8), the merge takes its readers, the un-delete succeeds (seq 12) and readers see 3 documents, the merge commits without a, and the commit (seq 15) has 0 hits for a. Select the still to open it at full size. Generated with AI, checked against the code.- Turn on soft deletes and index three documents in two segments,
[a, b]and[c]. Soft-deleteawithupdateDocValues(id:a, soft = 1): readers now see 2 documents. - Start
forceMerge(1)(every run used it; a background merge goes through the same code, by reading). The merge takes its readers whileais still soft-deleted, so it leavesaout of the new segment. - While the merge runs, restore
awithtryUpdateDocValue(reader, docOf(a), soft = null), the documented un-delete. You expect success withastaying live, or -1 so you can retry. - The call succeeds: it returns sequence number 12, and a new reader shows 3 documents,
aincluded. - Then the merge commits without
a: 2 documents, 0 hits fora, no error,CheckIndexclean.commit()returns 15, above the un-delete's 12, and the committed index still has noa.
Applications that turn on soft deletes (off by default) and restore documents with the Expert tryUpdateDocValue null reset, when the merge does not keep the document: no retention policy, or a retention query that does not match it. The window runs from the merge taking its readers to its commit, at any scale; the loss is silent and survives commit.
Mechanism, terms and numbers712 words
With a soft-deletes field, a soft-deleted document stays in its segment, where it can be restored, until a merge reclaims it, and IndexWriter#tryUpdateDocValue(reader, docID, <soft-deletes field = null>) is the documented way to un-delete it. The javadoc promises that while the segment "has not been merged away" the update succeeds and returns a sequence number > 0, and otherwise the call returns -1. If a merge of that segment is already running, the call succeeds and an NRT reader shows the document again, but the merge took its readers while the document was soft-deleted and left it out, and the carry-over of changes made during the merge skips documents the merge dropped. When the merge commits the document is gone, with no exception, and the next commit persists the loss, although its sequence number is above the un-delete's. The same happens with a SoftDeletesRetentionMergePolicy whose query does not match the document; a retention query that keeps it makes the un-delete survive. Every release with the feature is affected, 7.4.0 (LUCENE-8298, 2018) through 10.5.2, and main. Returning -1 for an un-delete while the segment is merging makes the repro pass, and the core index package still passes with it.
- Soft delete: with
IndexWriterConfig#setSoftDeletesField("soft"), a document whosesoftdoc-values field has a value counts as deleted. It stays in its segment, so it can still be restored, until a merge leaves it out (or until the whole segment is dropped once all of its documents are deleted,IndexWriter.java:4128-4139). - Un-delete:
tryUpdateDocValue(reader, docID, new NumericDocValuesField("soft", (Long) null))removes the value. The javadoc says: "This can be used to un-delete a soft-deleted document" (IndexWriter.java:1714-1715). - Retention: a
SoftDeletesRetentionMergePolicykeeps soft-deleted documents that match its query in the merged segment; without it a merge drops every soft-deleted document. - Carry-over: changes made to a segment while it is being merged are re-applied to the merged segment when the merge commits, through the merge's doc map from old to new docIDs. A document the merge left out maps to -1.
- The call accepts a merging segment.
tryUpdateDocValue(IndexWriter.java:1721) hands its change totryModifyDocument(:1791-1829), which applies it whenever the segment is still listed insegmentInfos(:1819) and has pooled reader state (:1820-1821, true for a segment of an NRT reader from this writer), and returns the next sequence number (:1824); otherwise it returns -1 (:1828). A segment being merged stays listed until the merge commits, and nothing here checksmergingSegments(unlikedropDeletedSegment,:1837). - Readers see the un-delete. The reset lands in the segment's pending updates, so the next NRT reader counts the document as live: 3 documents.
- The merge had already dropped it. The merge took its readers while the document was soft-deleted;
mergeMiddlepasses each one throughmerge.wrapForMerge(reader)(:5497), where only a retention policy could mark it live again. With no retention, or a query that does not match, the document is not written to the merged segment and maps to -1. - The carry-over skips it.
commitMergedDeletesAndUpdates(:4485-4556) maps every change through the doc map and skips -1: residual doc-values updates (:4644-4647) and the on-disk numeric, binary and sorted-numeric diffs (:4734-4737,:4766-4769,:4805-4808); hard deletes use the same test (:4847). The reset has nowhere to go. - The merge commits and the loss is permanent. The source segment that held the un-delete is replaced by a merged segment that never contained the document. No exception; the merge lines of IndexWriter's info stream that the diagram probe kept end with "0 new deletes since merge started" (the probe filters the stream; no warning is logged on this path, by reading).
CheckIndexis clean, and even a raw commit-point reader that ignores soft deletes finds no copy.
With a retention query that keeps the document, the merge writes it (still soft-deleted) into the merged segment, its doc-map entry is not -1, and step 4 carries the reset over: the un-delete survives. An un-delete made before the merge takes its readers also survives, and a tryDeleteDocument hard delete of a live document, made at the same point, is carried over.
Exposure and likelihood379 words
- Who is hit. Soft deletes are opt-in (
IndexWriterConfig#setSoftDeletesField, defaultnull); the un-delete is an Expert try* call. No caller oftryUpdateDocValuewas found in Elasticsearch, OpenSearch or Solr (code search on default branches, not exhaustive; nothing was run downstream). An unmerged 2018 Elasticsearch prototype used this un-delete, treated -1 as "retry" and kept rollback candidates with a retention query; it may have avoided this bug if that query always kept every document it restored, which was not established. - The window is long. Under the default
ConcurrentMergeSchedulermerges run in the background; the loss is shown for un-deletes that land after the merge took readers without the document and before it commits, which includes the time spent writing the merged segment. The earlier time, after the merge is registered and before it takes its readers, was not characterized. It is not a tight race: the repros hold the merge at three different points and fail every time (20 of 20 and 60 of 60 iterated runs). - Silent and permanent. The call reports success, NRT readers confirm it, and nothing signals the loss later. No copy is left in the current merged segment or the current commit point; only a reader opened before the merge committed, or an older commit kept by a deletion policy, may still hold it. In practice the application must notice and re-index it from its own source of truth.
- Sequence numbers mislead.
commit()'s sequence number describes "which changes are in the commit point and which are not" (IndexWriter.java:117-120), andcommit()'s javadoc says "All sequence numbers <= this value will be reflected in the commit" (:4208-4210); here it is above the un-delete's, yet the commit lacks it. - How common was not measured. The un-delete is niche (its own
IndexWriterConfigjavadoc still says it does not exist), and no one has reported the loss since 7.4.0; the registry rates the likelihood rare. - Workarounds until fixed: a
SoftDeletesRetentionMergePolicywhose query keeps every document the application may restore; or wait until the merges that were running when the document was restored have finished, then check that it is live and re-index it if not. Acommit()alone is not a sync point: merges can keep running after it (LiveIndexWriterConfig.java:467-470).
The test shows that a soft-deletes un-delete through IndexWriter#tryUpdateDocValue(reader, docID, <soft field = null>), made while a merge of the document's segment is running (from the merged-segment warmer), returns a sequence number > 0, and the merge then commits without the document.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.index;
import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestTryUndeleteDuringMerge extends LuceneTestCase {
public void testUndeleteDuringMergeIsDropped() throws IOException {
IndexWriterConfig iwc =
new IndexWriterConfig()
.setSoftDeletesField("soft")
.setMergePolicy(new LogDocMergePolicy())
.setMergeScheduler(new SerialMergeScheduler());
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
for (String id : new String[] {"a", "b"}) {
Document doc = new Document();
doc.add(new StringField("id", id, Field.Store.NO));
w.addDocument(doc);
}
w.updateDocValues(new Term("id", "a"), new NumericDocValuesField("soft", 1));
try (DirectoryReader reader = DirectoryReader.open(w)) {
long[] seqNo = {0};
w.getConfig()
.setMergedSegmentWarmer(
_ ->
seqNo[0] =
w.tryUpdateDocValue(
reader, 0, new NumericDocValuesField("soft", (Long) null)));
w.forceMerge(1);
int numDocs = w.getDocStats().numDocs;
// javadoc: > 0 = un-delete applied, else -1; actual: > 0, then the merge drops "a"
assertTrue(
"expected seqNo == -1 or numDocs == 2 after the merge, actual seqNo="
+ seqNo[0]
+ " numDocs="
+ numDocs,
seqNo[0] == -1 || numDocs == 2);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-451/, 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.TestTryUndeleteDuringMerge
- Expected vs actual
- Expected, per the
tryUpdateDocValuejavadoc: either-1or the acknowledged un-delete survives (numDocs 2). Actual:seqNo=7 numDocs=1.
More files, environment, full output43 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: expected seqNo == -1 or numDocs == 2 after the merge, actual seqNo=7 numDocs=1
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.assertTrue(Assert.java:42)
org.apache.lucene.index.TestTryUndeleteDuringMerge.testUndeleteDuringMergeIsDropped(TestTryUndeleteDuringMerge.java:55)- The javadoc: while the segment "has not been merged away" the update succeeds and returns > 0, "else, it returns -1". Here it returns > 0, then is reverted.
- Upstream's test of a
tryDeleteDocumentduring such a merge requires success, not -1; dropping the document suits a delete, not an un-delete. commit()is numbered above the un-delete but lacks it.
The full argument279 words
- The method's contract.
tryUpdateDocValue(IndexWriter.java:1708-1715): "If the provided reader is an NRT reader obtained from this writer, and its segment has not been merged away, then the update succeeds and this method returns a valid (> 0) sequence number; else, it returns -1 and the caller must then either retry the update and resolve the document again. ... This can be used to un-delete a soft-deleted document". - A running merge is not "merged away".
TestSoftDeletesRetentionMergePolicy#testDeleteDocWhileMergeThatIsSoftDeleted(:544-593) callstryDeleteDocumentfrom the merged-segment warmer, on the soft-deleted document that the merge drops, and asserts"seqId was -1", seqNo != -1(:582). Its comment: this "requires special logic inside IndexWriter#carryOverHardDeletes since docMaps are not created for this document". For a delete, the merge dropping the document is the requested result; for an un-delete it is the opposite, and no logic or test covers that case. Upstream'stryUpdateDocValueretry helperdoUpdate(used for the un-delete intestUndeleteDocumentand also for soft deletes) retries only on -1, "retry if we just committing a merge" (:781-792). - Sequence numbers. The class javadoc ties
commit()'s sequence number to the changes the commit contains (IndexWriter.java:117-120), andcommit()'s javadoc says "All sequence numbers <= this value will be reflected in the commit" (:4208-4210). - Precedent. LUCENE-4521 (#5587): a try* call that reported success but whose change was not persisted was fixed as a bug. The try* contract itself comes from LUCENE-4203 (#5275): success while the segment is not merged away, failure otherwise.
- Nothing documents the revert. No javadoc, CHANGES or MIGRATE text says an acknowledged un-delete may be undone by a merge that is already running.
The devil's-advocate check264 words
The strongest case for "intended", made by the intended-behaviour checker, the refute-lens review and the adjudication: CHANGES 7.4 for LUCENE-8298 says the null value "allows to undelete a soft-deleted document unless it's been claimed by a merge" (lucene/CHANGES.txt:7390-7393). Lucene uses "claim" for a merge's decision to reclaim deletes (SoftDeletesRetentionMergePolicy.java:42), and a merge takes that decision when it wraps its readers, so a running merge that does not retain the document has already claimed it. The same commit edited the carry-over for resets and kept the skip for dropped documents. IndexWriterConfig#setSoftDeletesField still says "Currently there is no API support to un-delete a soft-deleted document" (IndexWriterConfig.java:557-559). The try* methods are Expert, and the behaviour is eight years old.
Why it fails: at its strongest the CHANGES line says the un-delete may be impossible once a merge has claimed the document. It does not say the call may return success, show the document to readers and then be reverted; the API has a signal for "impossible", -1, and the same change's commit message states the limit in the javadoc's own words, "unless it's merged away". The IndexWriterConfig sentence came with LUCENE-8233, a month before the un-delete existed, and contradicts the tryUpdateDocValue javadoc: it is stale. A maintainer may still say that retention is the intended way to keep a soft-deleted document restorable, and that carrying a reset onto a document the merge left out is impossible without retaining it, aborting the merge or re-running merge work; that supports "return -1" or "document it", not "make it survive". Verdict: not intended.
Recommended Return -1 for an un-delete while the segment is merging (validated)
Make the call tell the truth: return -1 for an un-delete (a null soft-deletes value) while the document's segment is in mergingSegments (+13/-1 in IndexWriter.java). The repro then passes, and the core index package passes too (1,397 tests, 0 failures). Trade-off: it also refuses un-deletes a retain-all policy would have kept; the caller retries after the merge.
// tryUpdateDocValue: is this an un-delete (null value on the soft-deletes field)?
boolean undelete = false;
for (DocValuesUpdate update : dvUpdates) {
undelete |= update.hasValue() == false && update.field.equals(config.getSoftDeletesField());
}
return tryModifyDocument(readerIn, docID, undelete, (leafDocId, rld) -> { /* unchanged */ });
// tryModifyDocument(readerIn, docID, refuseWhileMerging, toApply): refuse while merging
if (refuseWhileMerging && mergingSegments.contains(info)) {
return -1;
}
if (segmentInfos.indexOf(info) != -1) { /* unchanged */ }All options and trade-offs4 options · 673 words
One fix was applied and tested in a scratch worktree at ea57360912ed; the others are sketches, not run.
tryUpdateDocValue marks the call as an un-delete when one of its fields is the soft-deletes field with a null value, and tryModifyDocument returns -1 for it while mergingSegments contains the segment, so the caller gets the documented failure signal and retries. Calls without a soft-field reset, and tryDeleteDocument, are unchanged (for documents the merge keeps, the carry-over applies them). A call that combines the reset with other fields is refused whole. A real patch should also say this in the tryUpdateDocValue javadoc and fix the stale IndexWriterConfig#setSoftDeletesField sentence.
// tryUpdateDocValue: is this an un-delete (null value on the soft-deletes field)?
boolean undelete = false;
for (DocValuesUpdate update : dvUpdates) {
undelete |= update.hasValue() == false && update.field.equals(config.getSoftDeletesField());
}
return tryModifyDocument(readerIn, docID, undelete, (leafDocId, rld) -> { /* unchanged */ });
// tryModifyDocument(readerIn, docID, refuseWhileMerging, toApply): refuse while merging
if (refuseWhileMerging && mergingSegments.contains(info)) {
return -1;
}
if (segmentInfos.indexOf(info) != -1) { /* unchanged */ }Trade-offs
- It also refuses un-deletes that would have survived. With a retain-all retention query, stock returns 11 and the un-delete survives; with the fix the call returns -1, and a retry after the merge (fresh reader, document resolved again) succeeds with sequence number 12 and the commit has the document. Without retention, the retry finds the document gone (
maxDoc2), so the caller learns it must re-index instead of believing the restore worked. - Retries wait for the merge. A caller that retries on -1 in a loop, like upstream's
doUpdatetest helper, spins until the merge commits. - Scope. It refuses for as long as the segment is registered for a merge, including the time before the merge takes its readers; whether an un-delete in that early part would have survived was not tested. A call that combines the soft-field reset with other fields is refused whole, and because the check looks only at the field and the null value, it also refuses that reset on a live document whose soft field is already empty.
- Cost. One set lookup per un-delete call. Not benchmarked.
What the checks validated
- This page's repro: 2 of 3 fail on stock, 3 of 3 pass with the fix, through gradle with
-Ptests.asserts=trueandfalse, and with the JUnit runner (-ea,-da). - The 18 closest core suites (soft deletes, try*, doc-values updates, merges,
TestIndexWriter,TestIndexWriterReader,TestAddIndexes,TestCheckIndexand others): 380 tests, 13 skipped, 0 failed, with assertions on (seed 1A2B3C4D) and off (seed 77AA77AA). - The whole core
org.apache.lucene.index.*package: 215 suites, 1,397 tests, 56 skipped, 0 failed (seed 1A2B3C4D, assertions on).:lucene:core:checkGoogleJavaFormatpasses. - Earlier: the finder's contract test fails 2 of 4 on stock and passes 4 of 4 with the fix; the security probe's loss test fails at the expected step with the fix (a vacuity check).
Return -1 only if a merge holding the segment has already taken its readers and the document is deleted in the merge's retention-applied reader.
Trade-offs
Keeps retain-all un-deletes working during merges, but the merge would have to keep its wrapped readers' live docs (today mergeMiddle keeps the wrapped reader local, IndexWriter.java:5494-5536), and the time before the readers are taken needs care. More code and state.
When an un-delete targets a document a running merge dropped, abort or re-run that merge so the document is merged as live.
Trade-offs
Keeps today's success semantics, but wastes the merge's work and interacts with forceMerge and merge-abort handling; not explored.
Say in the tryUpdateDocValue javadoc that an un-delete during a merge can be reverted when the merge commits, and point to a retention query as the way to avoid it.
Trade-offs
Cheapest, but callers that trust > 0 still lose data silently.
Model-based testing: an executable reference model of soft deletes ran against the real IndexWriter, with an operation that holds a forced merge after it took its readers and runs other operations meanwhile. An un-delete in that window left a document the model keeps live but Lucene drops; the AI tool then minimised it by hand to a JUnit test.
The method in full153 words
Method: model-based testing against an executable reference model (the hunt's "semi-formal" tier), a hunt unit (soft deletes), 2026-10-26, main ea57360912ed.
- The model tracks every document as live, soft-deleted, hard-deleted or retained, per segment, and compares the real writer after each step through NRT readers, commit points,
SoftDeletesDirectoryReaderWrapperandCheckIndex. Its file:line table maps each model element to the Lucene code. - The operation MERGE_CONC holds a forced merge inside
OneMerge#wrapForMergeafter the merge took its readers and runs sub-operations meanwhile. A TRY_REVIVE sub-operation (thetryUpdateDocValueun-delete) produced a document that the model keeps live after the merge but the implementation drops (model checkmerge-drops-revived-doc). - The finder minimised it by hand to a latch-driven JUnit on the stock jars. No model checker, SMT solver or prover was involved. To stay off known bugs the model never un-deletes hard-deleted documents (a separate registered bug) and runs try* calls only right after a refresh.
No prior report found (2026-10-26): about 250 read-only queries over GitHub issues and PRs (Lucene, Elasticsearch, OpenSearch, Solr), the dev@, java-user@, issues@ and commits@ lists, JIRA, code search and the files of all 408 open Lucene PRs. The closest text is the introducing change's CHANGES line: an intended-behaviour argument, not a report.
Searches5 searches
Verdict new. Keyword searches cannot prove absence, and private security reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, archived lucene-solr, Elasticsearch, OpenSearch, Solr, all of GitHub) | 2026-10-26 | 74 GraphQL searches plus two runs of a scripted prior-report search (100 planned queries over issues, PRs, commits, lists and JIRA); terms such as tryUpdateDocValue, tryModifyDocument, carryOverHardDeletes, commitMergedDeletesAndUpdates, mergingSegments, undelete / un-delete / revive / restore, "claimed by a merge", "merged away", lost / dropped after merge | no report of this defect |
| Mailing lists (dev@, java-user@, issues@, commits@, full archive) | 2026-10-26 | 55 queries | no report; a 2013 java-user thread is about the opposite symptom (try* returning -1) |
| JIRA (LUCENE project) | 2026-10-26 | 9 JQL queries; 16 issues read in full, including LUCENE-8233, 8293, 8297, 8298 | no report; LUCENE-8298's CHANGES wording ("unless it's been claimed by a merge") is the intended-behaviour risk |
| Related items (read) | 2026-10-26 | #9345 / LUCENE-8298, #5275 / LUCENE-4203, #9344 / LUCENE-8297, #9340 / LUCENE-8293, #5587 / LUCENE-4521, #11750, #9280, #9245, #11428, elastic/elasticsearch #31910 | different issues: the introducing change, the try* contract, the hard-delete mirror case, a try* success-not-persisted precedent (fixed as a bug), wasted writes after a merge, soft-delete design, and an unmerged downstream caller that relied on -1 |
| Open PRs and code | 2026-10-26 | changed files of all 408 open apache/lucene PRs (19 diffs grepped); code search for callers of tryUpdateDocValue; upstream main 49483e82b357 | #16571 and #16847 touch the code but would not fix it; no fix upstream |
Introduced in 7.4.0 by LUCENE-8298 (a3c86373e47d, backport 0c65af048b3a, committed 2018-05-09), which added the null reset that makes the un-delete possible, inside the carry-over branch that skips dropped documents. All 67 releases from 7.4.0 to 10.5.2 and main lose the un-delete; the parent commits cannot express it.
Versions tested6 rows
Introduced by LUCENE-8298 "Allow DocValues updates to reset a value" (now #9345), by @s1monw, reviewed as a JIRA patch (no GitHub PR): master a3c86373e47d and branch_7x 0c65af048b3a, authored 2018-05-07, committed 2018-05-09. The other ingredients are older: try* applies to any segment still listed (LUCENE-4203, 2012; shared with tryUpdateDocValue by LUCENE-8297 the same week), merges drop non-retained soft-deleted documents (LUCENE-8233), and the carry-over skips unmapped documents (LUCENE-5248 / LUCENE-7868; kept by main's unreleased carry-over rewrite #16570).
Not executed: branch_10_6 and 10.6.0 (released 2026-10-29), x86, JDKs other than 25. Not fixed upstream as of 2026-10-26.
| Version or commit | Verdict | Result |
|---|---|---|
main ea57360912ed | affected | lost: this page's test fails 2 of 3 (-ea, -da); the finder's 2 of 4; the portable probe; 10 of 10 iterations |
| 10.5.2, 9.12.3 | affected | lost: this page's test fails 2 of 3 (Maven Central jars, SHA-1 checked) |
| 7.4.0 to 10.5.2 (67 releases) | affected | lost on every one with the portable public-API probe, all controls passing; -da also on 7.4.0, 7.7.3, 8.11.4, 9.12.3, 10.5.2 |
a3c86373e47d (master), 0c65af048b3a (branch_7x) | bisect | LUCENE-8298: the probe loses the un-delete, controls ok (history builds of lucene-core) |
7873cf845e1d, 52e72467bb21 (the parents) | bisect | the un-delete cannot be expressed: no null doc-values value |
| 7.3.1 and earlier | not affected | no soft deletes and no tryUpdateDocValue (the probe does not compile) |
An independent reproducer with two other hold points, a devil's advocate for intended behaviour, an OpenAI GPT-5.6 reviewer with the intended lens and an adjudication all say real; a refute-lens GPT-5.6 review said not real only on procedure, since it could not run code. A credibility audit and fresh-seed re-runs agree.
Checks and verdicts275 words
All checks ran on main ea57360912ed unless noted, with fixed seeds recorded in their result files.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | Latch-driven JUnit: 2 of 4 fail with -ea and -da, 20 of 20 at 10 iterations, also on 10.5.2 and 9.12.3; both controls pass. |
| Refute review (OpenAI GPT-5.6, reading only) | not real (low), procedural | Could not run Java; agreed with the root cause and the inputs; its substantive objection is the CHANGES wording, answered above. A separate run re-ran the repro: 2 of 4 fail. |
| Independent reproducer | real | Own JUnit with two other hold points (merged-segment warmer; a merge blocked at its first output file): 3 of 6 fail on main, 10.5.2, 9.12.3, 9.0.0 and 8.11.4; 60 of 60 at 20 iterations; three controls pass. |
| Intended-lens review (OpenAI GPT-5.6, reading only) | real (medium) | The method's -1 contract and the sequence-number javadoc outweigh the CHANGES wording; nothing warns that a positive result can be reverted. |
| Intended-behaviour devil's advocate | real | Built the strongest "intended" case and rebutted it; its single-thread warmer probe loses the un-delete on main, 10.5.2, 9.12.3, 8.11.4 and 7.4.0; after the merge the stale reader gets -1. |
| Adjudication | confirmed (medium) | Re-ran the independent reproduction's JUnit with a fresh seed: 3 of 6 fail with -ea and -da, controls pass. |
| Security screen | none | Only the application's own Expert call triggers it; the document returns to its earlier deleted state; no other document changes, no hang, CheckIndex clean. |
| Credibility audit | keep | Fresh-seed re-runs on main (-ea, -da) and 10.5.2 (-da): 2 of 4 fail, controls pass. |
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 479 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,387 words
- Registry title
- An un-delete through IndexWriter#tryUpdateDocValue during a merge of the document's segment returns success and is visible to NRT readers, but the merge commit silently drops the document
- Area
- index / soft deletes (tryUpdateDocValue un-delete vs. a running merge's carry-over)
- Priority
- Medium, rank 22: A successful tryUpdateDocValue un-delete is silently and durably lost while a merge of its segment runs, a window that lasts the whole merge, but the expert API is niche.
- Impact class
- data corruption
- Impact note
- Applications that enable the opt-in soft-deletes field and restore soft-deleted documents with the Expert IndexWriter#tryUpdateDocValue (soft-deletes value set to null, the documented un-delete) lose the restore whenever a merge of that document's segment had already started and drops the soft-deleted document (any merge policy without retention, or a SoftDeletesRetentionMergePolicy whose query does not match it): the call returns a sequence number > 0 and NRT readers show the document, then the merge commit removes it with no error and the next commit persists the loss; ordinary scale, a window as long as the merge under the default ConcurrentMergeScheduler, every release from 7.4.0 to 10.5.2 and main; the index itself stays consistent (CheckIndex clean).
- Summary
- With a soft-deletes field, you soft-delete a document and later restore it with tryUpdateDocValue(reader, docID, <soft-deletes field = null>) while a merge of its segment is running. You expect what the javadoc promises: the segment has not been merged away, so the update succeeds and returns a sequence number > 0 and the document stays live (otherwise the call returns -1 and you retry). Instead the call does return > 0 and NRT readers show the document again, but the merge, which already treated it as deleted and left it out, commits without it, so the restored document disappears with no error and the next commit persists the loss.
- Introduced
- a3c86373e47ddd43368c5800eea63b50d2064759 (master, which became 8.0) and its branch_7x backport 0c65af048b3a497ea2e95e48c886b3a653412a0c (first released in 7.4.0): LUCENE-8298 'Allow DocValues updates to reset a value', now apache/lucene#9345, by s1monw (JIRA patch reviewed on JIRA, no GitHub PR) [exec: at the parents 7873cf845e1d (master) and 52e72467bb21 (branch_7x) the un-delete cannot be expressed (no null doc-values value; the probe's (Long) null throws an NPE), at a3c86373e47d and 0c65af048b3a the probe loses the un-delete with all controls passing; history builds of lucene-core only, validated against the official 7.4.0 jar] [read: the diff added the value reset inside the existing 'if (mappedDoc != -1)' carry-over block, so a reset on a document the merge dropped is skipped]; it was the last of four ingredients: try* applies to any segment still in segmentInfos (LUCENE-4203, 2012; shared with tryUpdateDocValue by LUCENE-8297, #9344, b0b32931b28d / 48a2138e899, same release), merges drop non-retained soft-deleted documents (LUCENE-8233, ecc17f90233), and the carry-over skips source documents without a merged docID (LUCENE-5248 / LUCENE-7868; kept by the unreleased main rewrite #16570, 9092bda09d6) (2018-05-09 (committed on master and branch_7x; authored 2018-05-07))
- First affected version
- 7.4.0 [exec: the official 7.4.0 jar loses the un-delete with -ea and -da]; 7.3.1 and earlier have no soft deletes, no tryUpdateDocValue and no null doc-values reset [exec: the probe does not compile on 7.3.1]
- Affected versions
- every release from 7.4.0 through 10.5.2 and main ea57360912ed. [exec] all 67 releases 7.4.0-10.5.2 and main lose the un-delete with the portable main() probe, controls passing on every one (history step, -ea; -da also on 7.4.0, 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main); the finder's JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit on main (-ea, -da), 10.5.2 (-ea, -da), 9.12.3 (-ea, -da), 9.0.0 and 8.11.4; the intended-behaviour check's probe on main (-ea, -da), 10.5.2, 9.12.3, 8.11.4 and 7.4.0. Not executed: 10.6.0 / branch_10_6, x86, JDKs other than 25 (all runs aarch64, JDK 25). No upstream fix as of 2026-10-26 (upstream main 49483e82b357 has no later commit touching the files involved).
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal
- Upstream and prior reports
- none found (2026-10-26, follow-up prior-report record verdict new: two scripted prior-report search runs of 100 planned queries, 74 GitHub GraphQL issue and PR searches over apache/lucene, the archived lucene-solr, Elasticsearch, OpenSearch, Solr and all of GitHub, 55 mailing-list queries over dev@, java-user@, issues@ and commits@, 9 JIRA JQL queries and 16 JIRA issues read in full, 5 code searches for tryUpdateDocValue callers, web searches, and a changed-file scan of all 408 open apache/lucene PRs (19 diffs grepped); the finder's 43-query and the intended-behaviour check's 51-query runs reused; none reports or discusses this bug). Not fixed: upstream main 49483e82b357 has no later commit touching the files involved, and no open PR changes tryModifyDocument's merge handling or the carry-over of un-deletes (#16571 refactors carry-over for multi-output merges, #16847 only widens the docID type; neither would fix it). Related, different: #9345 / LUCENE-8298 (the introducing change; its commit message 'unless it's merged away' and CHANGES 7.4 'unless it's been claimed by a merge' are design documentation and the main intended-behaviour risk, not a report), #5275 / LUCENE-4203 (the try* contract: success while not merged away, false otherwise), #9344 / LUCENE-8297, #9340 / LUCENE-8293 (hard-delete carry-over during a merge, the mirror case), #11750 (dropChanges after a merge commit; wasted writes, not a lost un-delete), #5587 / LUCENE-4521 (precedent: a try* call that reported success but was not persisted was treated as a bug), #9280, #9245, #11428 (soft-delete and un-delete design), elastic/elasticsearch #31910 (an unmerged 2018 caller that relied on -1 and a retention query).
- Lucene commit tested
- Lucene main
ea57360912ed(tested 2026-10-26) with the standalone JUnit runner and through gradle in a scratch worktree, with assertions on and off; this page's test also on the Maven Central jars 10.5.2 and 9.12.3 (SHA-1 checked); a portable probe on all 67 releases from 7.4.0 to 10.5.2; history builds of the two LUCENE-8298 commits and their parents. JDK 25.0.3, Linux aarch64 only.
- Finding record
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1707-1720 (tryUpdateDocValue javadoc: Expert; '> 0' while the segment has not been merged away, else -1; the null value un-deletes), :1791-1829 (tryModifyDocument: applies the change whenever segmentInfos.indexOf(info) != -1, no mergingSegments check), :4485 (commitMergedDeletesAndUpdates), :4644-4645, :4734-4735, :4766-4767, :4805-4806 (doc-values carry-over skips source documents with segDocMap.get(doc) == -1), :4847 (hard-delete carry-over, same skip)lucene/CHANGES.txt:7390-7393 (LUCENE-8298: 'This allows to undelete a soft-deleted document unless it's been claimed by a merge')lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:557-559 ('Currently there is no API support to un-delete a soft-deleted document', stale since 7.4)lucene/core/src/test/org/apache/lucene/index/TestSoftDeletesRetentionMergePolicy.java:548 (testDeleteDocWhileMergeThatIsSoftDeleted, the hard-delete mirror case) and :595 (testUndeleteDocument)
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1707-1784 (tryUpdateDocValue javadoc and body), :1791-1829 (tryModifyDocument), :1837 (dropDeletedSegment checks mergingSegments)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:4480-4556 (commitMergedDeletesAndUpdates), :4644-4647, :4734-4737, :4766-4769, :4805-4808 (skip md == -1), :4847 (hard-delete carry-over test), :5494-5536 (mergeMiddle wrapForMerge)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1819-1828 (listed and pooled, else -1), :4128-4139 (fully deleted segments dropped without a merge)
- Who is affected
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:541-562 (setSoftDeletesField; default null at :561; stale un-delete sentence :557-559)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:117-120 (sequence numbers and commit)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:4203-4210 (commit() javadoc), :5236-5241 (registerMerge adds to mergingSegments), :5484-5490 (initMergeReaders); LiveIndexWriterConfig.java:467-470 (merges may run on after a commit)
- Why it looks unintended
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1707-1720 (tryUpdateDocValue javadoc), :117-120 (sequence numbers)Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/index/TestSoftDeletesRetentionMergePolicy.java:544-593 (testDeleteDocWhileMergeThatIsSoftDeleted, assert at :582), :781-792 (doUpdate retries on -1)Lucene main ea57360912ed: lucene/CHANGES.txt:7390-7393 (LUCENE-8298)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SoftDeletesRetentionMergePolicy.java:36-45 ('claimed by merges')
- Suggested fix
Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/index/TestSoftDeletesRetentionMergePolicy.java:781-792 (doUpdate retry loop)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5494-5536 (mergeMiddle keeps the wrapped reader local)Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/index/TestSoftDeletesRetentionMergePolicy.java:683-687 (a multi-field tryUpdateDocValue call)
- TestTryUndeleteDuringMerge.java (JUnit test):
repro/LFV-451/src/org/apache/lucene/index/TestTryUndeleteDuringMerge.java(64 lines)
A summary diagram (PNG), produced separately from this text from executed probe runs on main (the finder's scenario: merge held after it took its readers, a second thread). Its sequence numbers (8, 12, 15) differ from the repro's (11, 13) because the two scenarios run different operations before the un-delete.
assets/LFV-451/bug.png(479 KB)
![Summary card: an un-delete with tryUpdateDocValue during a running merge is acknowledged, then silently lost. Left, what happens on main: 1, updateDocValues(id:a, soft = 1), seq 8, a is soft-deleted, segments _0 = [a, b] and _1 = [c]; 2, forceMerge(1) starts and takes its readers, a is deleted there so it maps to -1; 3, tryUpdateDocValue(reader, doc 0, soft = null), seq 12, an NRT reader shows a (3 documents); 4, the merge commits and the carry-over skips a, merged _2 = [b, c]; 5, commit(), seq 15, the committed index has 0 hits for id:a. Right, why: tryModifyDocument applies the change whenever the segment is still in segmentInfos, also while a merge reads it, and the carry-over in commitMergedDeletesAndUpdates skips documents mapped to -1. Expected: seq > 0 and a stays live, or -1 and the caller retries. Actual: seq 12 and a visible, then dropped. Not affected: an un-delete before the merge, or a retain-all retention policy; a tryDeleteDocument at the same point is carried over. Affected: Lucene 7.4.0 to 10.5.2 and main.](../assets/LFV-451/bug.png)