All findings · Lucene finding · LFV-451 · try-undelete-lost-by-running-merge

An un-delete with tryUpdateDocValue during a merge returns success, then the merge silently drops the document

With soft deletes, restoring a soft-deleted document with tryUpdateDocValue while a merge of its segment runs returns success and readers see the document again, but the merge then commits without it: 3 documents become 2 and the next commit has 0 hits for it. Every release from 7.4.0 to 10.5.2 and main is affected.

Summary

Priority
Medium, rank 22A 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
data corruption
Since
7.4.0 (LUCENE-8298, 2018)introduced 2018-05-09 · about 8 years
Affected
7.4.0 to 10.5.2 and main (all 67 releases run)
Status
documented
Found by
Model-based testing against an executable soft-deletes model (semi-formal)

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

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.

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.
The five steps on main: soft-delete 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.
  1. Turn on soft deletes and index three documents in two segments, [a, b] and [c]. Soft-delete a with updateDocValues(id:a, soft = 1): readers now see 2 documents.
  2. Start forceMerge(1) (every run used it; a background merge goes through the same code, by reading). The merge takes its readers while a is still soft-deleted, so it leaves a out of the new segment.
  3. While the merge runs, restore a with tryUpdateDocValue(reader, docOf(a), soft = null), the documented un-delete. You expect success with a staying live, or -1 so you can retry.
  4. The call succeeds: it returns sequence number 12, and a new reader shows 3 documents, a included.
  5. Then the merge commits without a: 2 documents, 0 hits for a, no error, CheckIndex clean. commit() returns 15, above the un-delete's 12, and the committed index still has no a.

Who is affected

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.

Terms

  • Soft delete: with IndexWriterConfig#setSoftDeletesField("soft"), a document whose soft doc-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 SoftDeletesRetentionMergePolicy keeps 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 bug, step by step (code at ea57360912ed)

  1. The call accepts a merging segment. tryUpdateDocValue (IndexWriter.java:1721) hands its change to tryModifyDocument (:1791-1829), which applies it whenever the segment is still listed in segmentInfos (: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 checks mergingSegments (unlike dropDeletedSegment, :1837).
  2. 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.
  3. The merge had already dropped it. The merge took its readers while the document was soft-deleted; mergeMiddle passes each one through merge.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.
  4. 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.
  5. 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). CheckIndex is 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, default null); the un-delete is an Expert try* call. No caller of tryUpdateDocValue was 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 ConcurrentMergeScheduler merges 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), and commit()'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 IndexWriterConfig javadoc 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 SoftDeletesRetentionMergePolicy whose 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. A commit() alone is not a sync point: merges can keep running after it (LiveIndexWriterConfig.java:467-470).

Minimal repro

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.

TestTryUndeleteDuringMerge.java (JUnit test)64 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 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);
      }
    }
  }
}
Command
# 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 tryUpdateDocValue javadoc: either -1 or the acknowledged un-delete survives (numDocs 2). Actual: seqNo=7 numDocs=1.

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

More files, environment, full output43 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 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)

Why it looks unintended

  • 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 tryDeleteDocument during 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) calls tryDeleteDocument from 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's tryUpdateDocValue retry helper doUpdate (used for the un-delete in testUndeleteDocument and 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), and commit()'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.

Suggested fix

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.

Return -1 for an un-delete while the segment is merging (validated) (recommended)

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 (maxDoc 2), 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 doUpdate test 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=true and false, and with the JUnit runner (-ea, -da).
  • The 18 closest core suites (soft deletes, try*, doc-values updates, merges, TestIndexWriter, TestIndexWriterReader, TestAddIndexes, TestCheckIndex and 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:checkGoogleJavaFormat passes.
  • 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).

Refuse only when the running merge has really dropped the document (not run)

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.

Make the success true by redoing the merge (not run)

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.

Documentation only (not run)

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.

How it was found

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, SoftDeletesDirectoryReaderWrapper and CheckIndex. Its file:line table maps each model element to the Lucene code.
  • The operation MERGE_CONC holds a forced merge inside OneMerge#wrapForMerge after the merge took its readers and runs sub-operations meanwhile. A TRY_REVIVE sub-operation (the tryUpdateDocValue un-delete) produced a document that the model keeps live after the merge but the implementation drops (model check merge-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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, archived lucene-solr, Elasticsearch, OpenSearch, Solr, all of GitHub)2026-10-2674 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 mergeno report of this defect
Mailing lists (dev@, java-user@, issues@, commits@, full archive)2026-10-2655 queriesno report; a 2013 java-user thread is about the opposite symptom (try* returning -1)
JIRA (LUCENE project)2026-10-269 JQL queries; 16 issues read in full, including LUCENE-8233, 8293, 8297, 8298no 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 #31910different 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 code2026-10-26changed 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

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedlost: 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 affectedlost: this page's test fails 2 of 3 (Maven Central jars, SHA-1 checked)
7.4.0 to 10.5.2 (67 releases) affectedlost 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) bisectLUCENE-8298: the probe loses the un-delete, controls ok (history builds of lucene-core)
7873cf845e1d, 52e72467bb21 (the parents) bisectthe un-delete cannot be expressed: no null doc-values value
7.3.1 and earlier not affectedno soft deletes and no tryUpdateDocValue (the probe does not compile)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateLatch-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), proceduralCould 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 realOwn 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 realBuilt 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 noneOnly 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 keepFresh-seed re-runs on main (-ea, -da) and 10.5.2 (-da): 2 of 4 fail, controls pass.

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 notes1,387 words

Finding record

LFV-451, snapshot of 2026-11-06

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.

Lucene and upstream sources

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)

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

Repro files

About the diagrams

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.

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.