All findings · Lucene finding · LFV-617 · upgrade-leftover-merge-bypasses-wrapped-policy

Upgrading an index with a single old segment drops the soft-deleted documents SoftDeletesRetentionMergePolicy keeps

Upgrade a soft-deletes index with IndexUpgrader and a SoftDeletesRetentionMergePolicy around the default TieredMergePolicy that keeps every soft-deleted document, and a lone old segment of 13 documents (3 of them soft-deleted) comes out with 10, with no error. Releases 7.4.0 to 10.6.0 (10.6.0 by reading) and main are affected.

Summary

Priority
Low, rank 315Needs the unusual composition of soft-deletes retention with IndexUpgrader or UpgradeIndexMergePolicy, which the large soft-deletes users do not call.
Impact
silent wrong results
Since
7.4.0 (LUCENE-8233, 2018: soft deletes)introduced 2018-04-04 · about 8 years
Affected
7.4.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
code reading while modelling the index tools, then a JUnit on released-version indexes

AI-assisted page: this finding was first signalled by code reading or testing 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

SoftDeletesRetentionMergePolicy keeps soft-deleted documents through merges, for example as change history. IndexUpgrader rewrites old segments in the current format by wrapping your merge policy in UpgradeIndexMergePolicy and calling forceMerge(1). Together they should rewrite every old segment and keep every retained document.

Step-by-step diagram of IndexUpgrader on an index written by Lucene 10.5.2 with one segment of 13 documents, 3 of them soft-deleted and kept by SoftDeletesRetentionMergePolicy: upgrade() wraps the configured policy in UpgradeIndexMergePolicy; TieredMergePolicy declines the lone old segment; UpgradeIndexMergePolicy builds its own merge, which the retention policy never wraps; the merge copies only the 10 live documents and the upgrade commits 13 to 10 documents with no error. Control: the same config with two old segments keeps all 13.
Step by step through the upgrade of the 13-document index written by 10.5.2: the upgrade policy wraps yours, the delegate declines the lone old segment, the upgrade's own merge skips the retention policy and 10 documents remain; with two old segments all 13 are kept. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Your config keeps soft-deleted documents: setSoftDeletesField("soft") and new SoftDeletesRetentionMergePolicy("soft", MatchAllDocsQuery::new, new TieredMergePolicy()). An index written by 10.5.2 has one segment with 13 documents: 10 visible and 3 soft-deleted, which the policy keeps.
  2. Upgrade it on main with new IndexUpgrader(dir, config, false).upgrade(). You expect the segment rewritten in the current format with all 13 documents, which is what happens when the same index has two old segments.
  3. IndexUpgrader wraps your policy in UpgradeIndexMergePolicy, which first asks your policy to merge the old segment. TieredMergePolicy sees one segment with no hard deletes and answers "already merged".
  4. So UpgradeIndexMergePolicy rewrites the segment with a plain merge of its own. The retention policy only decorates merges it returns itself, so it never sees this one, and the merge copies only the 10 live documents.
  5. You get 10 documents: the 3 retained ones are gone from the index (the upgrade keeps only its new commit), with no error. Searches that hide soft-deleted documents still return the same 10.

Who is affected

Applications that keep soft-deleted documents with SoftDeletesRetentionMergePolicy and upgrade with IndexUpgrader(dir, config, ...), or with UpgradeIndexMergePolicy around the retention policy. With the default TieredMergePolicy as the delegate, every lone old segment without hard deletes is hit (7.5.0 and later), such as a force-merged index. The retained documents are lost from the index; the visible documents do not change.

Mechanism, terms and numbers888 words

SoftDeletesRetentionMergePolicy "allows to carry over soft deleted documents across merges"; applications use it to keep recently deleted or updated documents, for example as change history. Upgrading such an index with new IndexUpgrader(dir, config, false).upgrade(), or with UpgradeIndexMergePolicy wrapped around the retention policy as that class's javadoc recipe shows, drops every soft-deleted document of an old segment that the delegate merge policy declines to merge. With TieredMergePolicy as the delegate (the default, and the one in the example), that is always so on 7.5.0 and later for a lone old segment without hard deletes, the usual shape of a force-merged index. An index written by 10.5.2 with 13 documents (10 visible, 3 soft-deleted and retained by a match-all query) comes out with 10; with two old segments it keeps all 13. There is no error, and the upgrade keeps only its new commit, so only a backup made before the upgrade still has them; the visible documents, as seen by readers that apply soft deletes, do not change. Cause: UpgradeIndexMergePolicy.findForcedMerges rewrites the old segments its delegate did not select with its own new OneMerge(newInfos), and OneMergeWrappingMergePolicy, the base class of the retention policy, only wraps merges it returns itself. Data loss since 7.4.0, when soft deletes arrived; the leftover merge itself dates from 2011.

Terms

  • Soft deletes: with IndexWriterConfig.setSoftDeletesField("soft"), softUpdateDocument, or updateDocValues on the soft-deletes field, hides a document by giving it a doc value in that field instead of marking it deleted. Readers obtained from the IndexWriter, and SoftDeletesDirectoryReaderWrapper, skip it (a plain DirectoryReader does not); a merge drops it unless the merge policy keeps it.
  • SoftDeletesRetentionMergePolicy: a OneMergeWrappingMergePolicy that wraps each merge so that soft-deleted documents matching a retention query stay in the merged segment (SoftDeletesRetentionMergePolicy.java:36-46).
  • OneMergeWrappingMergePolicy: "A wrapping merge policy that wraps the OneMerge objects returned by the wrapped merge policy" (OneMergeWrappingMergePolicy.java:23-25). Its four segment-based merge-selection methods return wrapSpec(in.find*Merges(...)) (:44-83); findMerges(CodecReader...), the addIndexes path, is inherited from FilterMergePolicy unwrapped, a separate gap not claimed here.
  • UpgradeIndexMergePolicy and IndexUpgrader: the upgrade policy turns forceMerge into "rewrite every segment written by another version"; IndexUpgrader.upgrade() installs it around the configured policy (IndexUpgrader.java:180), keeps only the last commit (:181), runs forceMerge(1) (:196) and commits (:204).

The bug

  1. The delegate is asked first. UpgradeIndexMergePolicy.findForcedMerges collects the old segments (UpgradeIndexMergePolicy.java:86-93) and asks its delegate, here the retention policy around TieredMergePolicy, to merge them (:101-102).
  2. The delegate (TieredMergePolicy) declines a lone old segment without hard deletes. On the writer the upgrade has just opened, the segment is not pooled yet, so IndexWriter.numDeletesToMerge returns only hard deletes ("this is safe!", IndexWriter.java:6449-6455); TieredMergePolicy sees one segment with no deletes and returns nothing (logging "already merged" only with an InfoStream, TieredMergePolicy.java:835-847). With a retain-all query it would decline even with exact counting, so this estimate is not the defect; for a query that keeps only some soft-deleted documents it is a second ingredient, since exact counting would leave reclaimable deletes and TieredMergePolicy would merge (and the wrapper wrap) the segment itself (code reading).
  3. The upgrade policy merges the leftovers itself. It logs "does not want to merge all old segments, merge remaining ones into new segment" (:117-125, only with an InfoStream) and adds spec.add(new OneMerge(newInfos)) (:136), a plain merge created outside the delegate. Its other methods still delegate; only the wrapForMerge decoration of this merge is bypassed.
  4. The retention wrapper never sees it. Only merges returned through wrapSpec are wrapped. IndexWriter.mergeMiddle calls merge.wrapForMerge(reader); the plain merge returns the reader unchanged, and soft-deleted documents are kept only for a wrapped reader (IndexWriter.java:5497-5501). The new segment holds the live documents only.

With two old segments, TieredMergePolicy merges them itself, that merge comes back through wrapSpec, and every retained document survives. A probe logged the delegate's view (numDeletesToMerge=0 delCount=0 softDel=3), the plain MergePolicy$OneMerge returned for the segment, and maxDoc=10 soft=0 after the upgrade.

The numbers (main, index written by 10.5.2: 10 visible + 3 soft-deleted = 13)

scenarioexpectedgot
IndexUpgrader(dir, retainAllConfig, false), one old segment (flushed or force-merged)1310
retention query that keeps 2 of the 31210
partially upgraded index (the old segment plus one written by main)14 / 1511 / 12
the UpgradeIndexMergePolicy javadoc recipe and forceMerge(1)1310
LogByteSizeMergePolicy or LogDocMergePolicy as the delegate, lone old segment: compound file / not1310 / 13
control: two old segments1313
control: lone old segment that also has a hard deleteall 3 keptall 3 kept
control: retention policy outermost around UpgradeIndexMergePolicy1313
new IndexUpgrader(dir) (the command line builds the same config; by reading)upgradedIllegalArgumentException: soft-deletes field not configured

In every loss case the 10 visible documents (through readers that apply soft deletes) are unchanged and the index is consistent; only the retained soft-deleted documents disappear, also for a plain DirectoryReader, which could still find them before the upgrade. With a LogMergePolicy delegate, the delegate merges a lone non-compound-file old segment itself, wrapped; it declines a small compound-file one, whose compound-file flag already matches what the writer would write (MergePolicy.java:750-758), and that one loses the documents.

Exposure and likelihood309 words
  • Who. Applications that keep soft-deleted documents with SoftDeletesRetentionMergePolicy (opt-in; for example change history for replication or recovery, or recent versions for a time window) and upgrade the index format with IndexUpgrader(dir, config, deletePriorCommits), or with UpgradeIndexMergePolicy around the retention policy and forceMerge. Main's MIGRATE.md (11.0) suggests UpgradeIndexMergePolicy to "upgrade segment formats immediately". For a soft-deletes index the config constructor is the only way to run IndexUpgrader: the default constructors and the command line cannot open it.
  • Trigger. Deterministic once an old segment has no hard deletes and the delegate does not select it. With TieredMergePolicy as the delegate (the default) that is always so on 7.5.0 and later for a lone old segment (a force-merged or small one-segment index), and for a partially upgraded index that holds one such segment; on 7.4.0 only for a non-compound-file one. With a LogMergePolicy delegate only a lone compound-file old segment is declined. Any retention query that matches at least one soft-deleted document can be affected, not only match-all; for partial retention the fresh writer's hard-deletes-only estimate is part of the trigger. No scale, concurrency or special data.
  • Effect. Silent loss of the documents the retention policy exists to keep, permanent for the index: the upgrade keeps only its new commit, so the previous commit is deleted; only a backup or copy made before the upgrade still has them. The visible documents (through readers that apply soft deletes) and the hard deletes are unchanged and the index is consistent; no deleted document comes back. Anything that later reads the retained history, including a plain DirectoryReader search, which does not apply soft deletes, finds it missing.
  • How rare. A judgement, not measured: the combination is uncommon, IndexUpgrader is optional, no Lucene test combines these classes, and Elasticsearch and OpenSearch (default branches, code reading) do not call Lucene's IndexUpgrader or UpgradeIndexMergePolicy.

Minimal repro

The test shows that UpgradeIndexMergePolicy around a SoftDeletesRetentionMergePolicy (the nesting IndexUpgrader builds) rewrites a lone old segment with its own unwrapped merge, so the soft-deleted documents the retention policy must keep are purged.

TestUpgradeIndexMergePolicyRetainsSoftDeletes.java (JUnit test)60 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.search.MatchAllDocsQuery;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestUpgradeIndexMergePolicyRetainsSoftDeletes extends LuceneTestCase {

  public void testLoneOldSegmentKeepsRetainedSoftDeletes() throws IOException {
    try (Directory dir = newDirectory()) {
      IndexWriterConfig iwc = new IndexWriterConfig().setSoftDeletesField("soft");
      iwc.setMergePolicy(NoMergePolicy.INSTANCE);
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        Document doc = new Document();
        doc.add(new StringField("id", "1", Field.Store.NO));
        w.addDocument(doc);
        w.softUpdateDocument(new Term("id", "1"), doc, new NumericDocValuesField("soft", 1));
      }
      String oldSegment = SegmentInfos.readLatestCommit(dir).info(0).info.name;
      MergePolicy upgrade =
          new UpgradeIndexMergePolicy(
              new SoftDeletesRetentionMergePolicy(
                  "soft", MatchAllDocsQuery::new, new TieredMergePolicy())) {
            @Override
            protected boolean shouldUpgradeSegment(SegmentCommitInfo si) {
              return si.info.name.equals(oldSegment);
            }
          };
      iwc = new IndexWriterConfig().setSoftDeletesField("soft").setMergePolicy(upgrade);
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        w.forceMerge(1);
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        // expected: the retained soft-deleted doc survives the upgrade (maxDoc 2); actual: maxDoc 1
        assertEquals(2, r.maxDoc());
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-617/, 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.TestUpgradeIndexMergePolicyRetainsSoftDeletes
Expected vs actual
Expected maxDoc 2 after forceMerge(1) (one live plus one retained soft-deleted doc); actual 1 (expected:<2> but was:<1>), with -ea and -da.

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

More files, environment, full output34 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<2> but was:<1>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.index.TestUpgradeIndexMergePolicyRetainsSoftDeletes.testLoneOldSegmentKeepsRetainedSoftDeletes(TestUpgradeIndexMergePolicyRetainsSoftDeletes.java:56)

Why it looks unintended

  • IndexUpgrader puts the upgrade policy around yours while saying it upgrades "using the given config"; for a soft-deletes index that is the only way to run it.
  • Same tool, config and documents: two old segments keep 13 documents, one keeps 10.
  • The leftover merge predates merge wrapping and soft deletes; no test combines them.
The full argument288 words

No javadoc sentence literally promises that an upgrade keeps retained soft deletes; the case rests on consistency.

  • Contracts. SoftDeletesRetentionMergePolicy: "allows to carry over soft deleted documents across merges ... allows to control when soft deletes are claimed by merges" (SoftDeletesRetentionMergePolicy.java:37-42). UpgradeIndexMergePolicy: "All other methods delegate to the base MergePolicy given to the constructor", "forceMerge does no longer really merge; it is just used to 'forceMerge' older segment versions away", and its recipe iwc.setMergePolicy(new UpgradeIndexMergePolicy(iwc.getMergePolicy())) puts the configured policy inside (UpgradeIndexMergePolicy.java:27-31, :36-42). IndexUpgrader(Directory, IndexWriterConfig, boolean) upgrades "using an IndexWriter using the given config" (IndexUpgrader.java:154-158) and its class javadoc expects a user policy ("The MergePolicy set by IndexWriterConfig may also reorder documents", :51-52). IndexUpgrader is not @lucene.experimental.
  • The inconsistency. With the same tool, config and documents, two old segments keep 13 and one keeps 10; a lone segment with one hard delete keeps all its soft-deleted documents and one without loses them. With a LogMergePolicy delegate the outcome also depends on whether the lone old segment is a compound file.
  • History. The leftover merge is from LUCENE-3082 (2011, trunk 600a50bb2ae; 3.2 via 535d974518b), six years before OneMergeWrappingMergePolicy (LUCENE-7688, 2017, master 6f9acb51549; 6.5.0 via 180614c1491) and seven before soft deletes (LUCENE-8233, 2018, 7.4.0); none of LUCENE-8233's 15 comments mentions upgrading. No evidence was found that this interaction was considered or later addressed.
  • Tests. No upstream test combines IndexUpgrader or UpgradeIndexMergePolicy with SoftDeletesRetentionMergePolicy; TestUpgradeIndexMergePolicy runs the generic merge-policy tests over a plain TieredMergePolicy.
  • Upstream remark. On LUCENE-8264 (#9311) a committer (@s1monw) pointed to UpgradeIndexMergePolicy for upgrades and to OneMergeWrappingMergePolicy "similar to what we do in the soft delete case in SoftDeletesRetentionMergePolicy", presenting them as composable.
The devil's-advocate check248 words

The intended-behaviour verifier built the strongest case for "not a bug": OneMergeWrappingMergePolicy only promises to wrap the merges its delegate returns, so the leftover merge was never in scope; IndexWriterConfig.setSoftDeletesField says documents are retained "if the merge policy modifies the live docs of a merge reader" (IndexWriterConfig.java:551-552), and the writer's top policy, UpgradeIndexMergePolicy, does not; a direct user can put the retention policy outermost, which works; @mikemccand wrote on #8574 that the upgrade policy "was really designed for one-off usage via the IndexUpgrader tool"; the merge-policy classes involved are @lucene.experimental; and only invisible documents are lost.

Why it fails: IndexUpgrader forces the losing nesting itself while saying it uses the given config, and its default constructor and command line cannot open a soft-deletes index, so the tool offers no safe way; the UpgradeIndexMergePolicy recipe nests the same way; the IndexWriterConfig sentence refers to the policy the user configured, not one the tool put around it; "one-off usage via the IndexUpgrader tool" is exactly this case; and @lucene.experimental permits API changes, not silently dropping documents a configured policy keeps. The verifier, the independent reproducer and cross-model reviews rated the case moderate. The cross-model fact check found this rebuttal mostly fair but noted that "using the given config" and "All other methods delegate" carry limited contractual weight (IndexUpgrader already replaces the merge and deletion policies); the strongest argument is the behavioural inconsistency. Verdict: a defect; a maintainer may still call it a composition gap.

Suggested fix

Recommended B: a MergePolicy.wrapMerge(OneMerge) hook for merges created by a wrapping policy

Let the delegate decorate UpgradeIndexMergePolicy's own merge too: a small MergePolicy.wrapMerge(OneMerge) hook, implemented by OneMergeWrappingMergePolicy and called for the leftover merge (+22/-1, four files). The three repros pass, and so do 450 tests (76 skipped) in 17 classes: the repros and the 14 closest upstream classes. A variant without an API change passes the same tests.

// MergePolicy: by default a policy does not decorate merges created around it
public OneMerge wrapMerge(OneMerge merge) { return merge; }
// FilterMergePolicy
@Override public OneMerge wrapMerge(OneMerge merge) { return in.wrapMerge(merge); }
// OneMergeWrappingMergePolicy (and so SoftDeletesRetentionMergePolicy)
@Override public OneMerge wrapMerge(OneMerge merge) { return wrapOneMerge.apply(in.wrapMerge(merge)); }
// UpgradeIndexMergePolicy.findForcedMerges, the leftover merge
spec.add(in.wrapMerge(new OneMerge(newInfos)));   // was: spec.add(new OneMerge(newInfos))
All options and trade-offs4 options · 706 words

Two fixes were applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF): the three repro classes (11 tests; stock: 4 failures, assertions on and off) and 14 closest upstream classes in core, backward-codecs (which runs IndexUpgrader on real old-version indexes) and misc. Either fix makes every repro pass. Which one, or another design, is the maintainers' call.

B: a MergePolicy.wrapMerge(OneMerge) hook for merges created by a wrapping policy (validated by the checks) (recommended)

MergePolicy.wrapMerge returns the merge unchanged by default ("decorates a merge that a policy wrapping this one created itself"); FilterMergePolicy delegates it; OneMergeWrappingMergePolicy applies its operator after its delegate's; UpgradeIndexMergePolicy passes its leftover merge through it. Patch: optionB-mergepolicy-wrapmerge-hook.diff, +22/-1 in four files.

// MergePolicy: by default a policy does not decorate merges created around it
public OneMerge wrapMerge(OneMerge merge) { return merge; }
// FilterMergePolicy
@Override public OneMerge wrapMerge(OneMerge merge) { return in.wrapMerge(merge); }
// OneMergeWrappingMergePolicy (and so SoftDeletesRetentionMergePolicy)
@Override public OneMerge wrapMerge(OneMerge merge) { return wrapOneMerge.apply(in.wrapMerge(merge)); }
// UpgradeIndexMergePolicy.findForcedMerges, the leftover merge
spec.add(in.wrapMerge(new OneMerge(newInfos)));   // was: spec.add(new OneMerge(newInfos))

Trade-offs

  • A new public expert method on the @lucene.experimental MergePolicy; any decorating policy can opt in. BPReorderingMergePolicy (misc) would need its own override, not written here, to reorder this merge.
  • MergePolicy is widely subclassed: experimental status lowers compatibility expectations, but a clash with an existing downstream method of the same name, and the order in which mixed custom decorators apply, deserve review; the 450 tests cover no outside policies.
  • No CHANGES entry, javadoc build or full check was run; no performance effect expected (one call per upgrade merge).

What the checks validated

  • Stock: the three repro classes run 11 tests with 4 failures, assertions on and off.
  • With B: 11 of 11 pass with assertions on and off. With the 14 closest classes (TestUpgradeIndexMergePolicy, TestOneMergeWrappingMergePolicy, TestSoftDeletesRetentionMergePolicy, TestSoftDeletesDirectoryReaderWrapper, TestPendingSoftDeletes, TestFilterMergePolicy, TestMergePolicy, TestTieredMergePolicy, TestIndexWriterForceMerge, TestIndexWriterMergePolicy, TestSizeBoundedForceMerge; TestIndexUpgradeBackwardsCompatibility, TestEmptyIndexBackwardsCompatibility; TestBPReorderingMergePolicy): 17 classes, 450 tests, 76 skipped, 0 failures. TestFilterMergePolicy checks that FilterMergePolicy overrides every MergePolicy method.
  • :lucene:core:checkGoogleJavaFormat passes with the patch and the compact test.

A: wrap the leftover merge like the delegate chain would (no API change) (validated by the checks)

UpgradeIndexMergePolicy walks its FilterMergePolicy chain and applies each OneMergeWrappingMergePolicy's operator to its own merge, innermost first, as wrapSpec does for returned merges; OneMergeWrappingMergePolicy gets a package-private wrapOneMerge(OneMerge). Patch: optionA-wrap-leftover-merge-like-delegate.diff, the finder's sketch, +24/-1 in two files.

private OneMerge wrapLikeDelegate(OneMerge merge) {
  ArrayDeque<OneMergeWrappingMergePolicy> wrappers = new ArrayDeque<>();
  for (MergePolicy p = in; p instanceof FilterMergePolicy f; p = f.in) {
    if (p instanceof OneMergeWrappingMergePolicy w) wrappers.push(w);
  }
  while (wrappers.isEmpty() == false) merge = wrappers.pop().wrapOneMerge(merge);
  return merge;
}
// findForcedMerges: spec.add(wrapLikeDelegate(new OneMerge(newInfos)));

Trade-offs

  • No public API change, but it only reaches OneMergeWrappingMergePolicy subclasses through FilterMergePolicy.in; another decorator in the chain, such as BPReorderingMergePolicy, stays bypassed (by reading only). It reaches into another class's operator, which a maintainer may find too clever.

What the checks validated

  • The three repro classes 11 of 11 pass (assertions on); the same 17 classes, 450 tests, 76 skipped, 0 failures. Earlier the finder's build passed its 2 tests and 19 upstream merge-policy tests, and the independent reproducer's and the adjudication's runs passed 6 of 6 and 2 of 2 with it.

Workaround: retention policy outermost, without IndexUpgrader (validated by the checks)

Open an IndexWriter with new SoftDeletesRetentionMergePolicy(field, query, new UpgradeIndexMergePolicy(new TieredMergePolicy())), run forceMerge(1) and commit (IndexUpgrader also sets KeepOnlyLastCommitDeletionPolicy, which is already the default deletion policy; set it only if your config uses another one). The retention wrapper then also wraps the upgrade policy's own merge.

Trade-offs

  • Users must know about it; IndexUpgrader itself cannot be configured this way.

What the checks validated

  • testRetentionPolicyOutermost and the independent reproducer's and devil's advocate's controls keep all 13 documents (and 12 or 11 under partial retention).

C: document the limitation

Warn in IndexUpgrader and UpgradeIndexMergePolicy that decorating delegates do not see the leftover merge and that a retention policy must be outermost.

Trade-offs

  • Cheapest, but IndexUpgrader still cannot upgrade such an index safely. Not a fix either: counting soft deletes for unpooled segments in numDeletesToMerge, since with a retain-all query TieredMergePolicy would still decline the segment.

Downstream note

Elasticsearch and OpenSearch, the large soft-deletes users, do not call Lucene's IndexUpgrader or UpgradeIndexMergePolicy (default branches, code reading), but Elasticsearch 7.17 and OpenSearch main have their own upgrade merge policies that add a merge of their own around a nested retention policy in a similar way (code reading, nothing run).

How it was found

Code reading, not a formal method. While modelling which index tools may change which documents for a round-trip test of the index tools, the finder read UpgradeIndexMergePolicy.findForcedMerges and saw that it merges leftovers itself. A public-API JUnit on indexes written by released 10.5.2 and 9.12.3 confirmed it; the randomized round-trip test never could, as it builds no old segments.

The method in full161 words

Method: code reading (non-formal tier), in a hunt unit that compares Lucene's index tools by round-tripping indexes through them (a randomized differential test).

  • While modelling the IndexUpgrader leg (which tools may change which documents), the finder read UpgradeIndexMergePolicy.findForcedMerges and noticed that the merge it creates for old segments its delegate declines is a plain OneMerge, which a wrapping delegate never sees.
  • It wrote fixture indexes with the released 10.5.2 and 9.12.3 jars (13 documents, 3 soft-deleted, one or two segments) and a public-API JUnit around new IndexUpgrader(dir, config, false): one segment loses the 3 retained documents, two keep them. A shadow build with the fix sketch passed.
  • The randomized differential itself did not find it: it builds no old-version segments and no retention policies.
  • Found 2026-10-26 on main a9779ef3c193; then checked by an independent reproducer, a devil's-advocate verifier, reviews by a model from another family (reading only), an adjudication, a follow-up (history, prior reports, security) and an audit.

Prior reports, history and checks

Prior reports

No prior report found (163 queries, 2026-10-26): GitHub issues, PRs and code, JIRA, the mailing lists, downstream projects and the changed files of all 407 open PRs. Nearest: #8574 (open), about the size of the same leftover merge.

Searches4 searches

Verdict new. The nearest items concern the same leftover-merge block or name the same classes, for other defects.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs, code and commits; Elasticsearch, OpenSearch, Solr2026-10-26two runs of the hunt's prior-report tool (class names, wrapForMerge, "merge remaining ones into new segment", soft-deletes and wrapping-order phrasings) and 26 manual GitHub searchesno report; same block, other defects: #8574 (LUCENE-7523, open: monster segments), #9052 (LUCENE-8004, Won't Fix), #8722 (LUCENE-7671, open); #9311 (LUCENE-8264) names the three policies for N-2 support
JIRA archive; dev@, java-user@, issues@, commits@2026-10-2615 JIRA queries (five issues read in full) and 40 list queriesno report
Open apache/lucene PRs2026-10-26changed files of all 407 open PRsnone touches UpgradeIndexMergePolicy.java; #16571 touches OneMergeWrappingMergePolicy and the retention wrapper for another bug and would not fix this
Earlier runs2026-10-26the finder's run (40 queries) and the devil's-advocate verifier's (27)no report

History

Data loss since 7.4.0 (LUCENE-8233, 2018, which added soft deletes and the retention policy); the leftover merge itself is from 2011 (LUCENE-3082). Executed on releases from 7.4.0 to 10.5.2 and on main: one old segment loses the documents, two keep them. Never fixed.

Versions tested7 rows

Data loss introduced by LUCENE-8233 "Add support for soft deletes to IndexWriter" (@s1monw, 2018-04-04; branch_7x 02fa5a0b059, master ecc17f90233; a JIRA patch, migrated as #9280), which added SoftDeletesRetentionMergePolicy as a OneMergeWrappingMergePolicy whose wrapper is the only thing that keeps retained documents through a merge. The defective line is older: spec.add(new OneMerge(newInfos)) comes from LUCENE-3082 (trunk 600a50bb2ae, 2011; 3.2 via 535d974518b), and it has bypassed OneMergeWrappingMergePolicy since that class appeared (LUCENE-7688, master 6f9acb51549; 6.5.0 via 180614c1491) without losing data before soft deletes. LUCENE-7976 (2018-06-15, 9c4e315c1cb / 2519025fdaf, 7.5.0) widened the trigger: before it, TieredMergePolicy merged a lone compound-file old segment itself.

The first ordinary major upgrade, 7.7.3 to 8.0.0, loses the documents. 7.4.0 is affected, but its ordinary trigger is unusual (an index written before 7.4 that started soft-deleting under 7.4, with a non-compound-file old segment); the first routine upgrade of an index that already had soft deletes is 7.4.0 to 7.5.0. Releases between the executed ones share the unchanged code path (read). No commit on main or branch_10x changes it; the branch_10_6 head (10.6.0, released 2026-10-29) and the unreleased branch_10x head have the same code (read, not run).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affected13 -> 10 for a 10.5.2- or 9.12.3-written lone segment and fixture-free; 11 tests, 4 failures through gradle, assertions on and off; two segments keep 13
10.5.2 affected13 -> 10 upgrading a 9.12.3-written index (assertions on and off); two segments keep 13
8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 affected13 -> 10 upgrading an index written by the previous major line; two segments keep 13 (8.0.0, 9.0.0, 9.12.3, 10.0.0)
7.5.0, 7.7.3 affected13 -> 10 upgrading a 7.4.0 index (compound file or not on 7.5.0; non-compound on 7.7.3); two segments keep 13 (7.5.0)
7.4.0 non-CFS segment onlya lone non-compound-file old segment loses the documents (assertions on and off); a compound-file one is merged by TieredMergePolicy itself and keeps them
6.5.0, 7.3.1 not affectedno soft deletes (7.3.1: compile error); the leftover merge already bypasses a merge-counting wrapper, without data loss
fix A or B (scratch worktree at a9779ef3c193) not affected11 of 11 repro tests pass; 17 classes (the 3 repros and the 14 closest upstream classes), 450 tests, 76 skipped, 0 failures

Independent checks

An independent reproducer and a devil's-advocate verifier reproduced it with their own tests on main, 10.5.2 and 9.12.3; an adjudication confirmed it (medium confidence) and an audit kept it. Reviews by a model from another family could only read the code; they agreed on the root cause.

Checks and verdicts488 words

Each executing check wrote or re-ran its own JUnit on the stock code; every control passed.

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realOwn fixture-free JUnit 2 of 6 fail and own IndexUpgrader JUnit on indexes written by 10.5.2, 9.12.3 and 8.11.4 2 of 2 fail, on main, 10.5.2 and 9.12.3, assertions on and off; found partial retention and the partially upgraded case; a logging probe showed the mechanism; the fix sketch makes everything pass.
devil's advocate real; medium-high that it is not intendedA 13-test composition probe on main: the default IndexUpgrader cannot open a soft-deletes index, the javadoc recipe loses the documents, a partially upgraded index loses them (15 -> 12), only the retention policy outermost keeps them.
cross-model reviews (refute, intended, root cause) not real (procedural), real, uncertain; root cause agreedAn independent reviewer from another model family (OpenAI GPT-5.6) could only read the code: the refute lens said not real with low confidence only because it could not run anything; the intended lens said real; the root-cause lens agreed on the cause and noted that the fix sketch does not cover other decorators. A separate run re-ran the repro for each lens: the intended and root-cause re-runs failed 1 of 2 as claimed; the refute re-run failed 2 of 2, including the two-segment control, because the fixtures were not yet embedded, after which the finder embedded them.
adjudication confirmed, mediumRe-ran the independent reproduction's tests: 2 of 6 fail with assertions on and off; IndexUpgrader on 10.5.2-written one-segment indexes 13 -> 10 and 14 -> 11; two segments keep them; with the fix all pass. Medium because a maintainer may call it a composition gap.
Follow-up and audit reproduces; keepHistory (59 runs from 6.4.2 to main), prior reports (new) and security screen (none); the registration check and the audit re-ran the finder's and the independent reproduction's tests on stock main with the same failures.
fact check of the doc (independent) bug and main claims hold; 5 text errors, fixedRe-ran the brief's gradle command and all three repro classes on a fresh extraction of a9779ef3c193 (11 tests, 4 failures, assertions on and off), both fixes (11 of 11) and the repro harness on main, 10.5.2 and 9.12.3. A probe with other delegates found that LogByteSizeMergePolicy and LogDocMergePolicy keep a lone non-compound-file old segment and lose a compound-file one, so the "always" claim now names TieredMergePolicy.
cross-model fact check of the brief bug real; 4 text errors, 6 overclaims, 5 missing caveats, all addressedThe independent reviewer from another model family (OpenAI GPT-5.6) read the code and the run records: the mechanism, repros, history, fixes and security classification are sound; it corrected the claim about unchanged search results (a plain DirectoryReader does not apply soft deletes) and the scope of OneMergeWrappingMergePolicy's wrapping, and rated the intended-behaviour case moderate.

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,448 words

Finding record

LFV-617, snapshot of 2026-11-06

Registry title
UpgradeIndexMergePolicy rewrites the old segments its delegate declines with its own plain OneMerge, which a wrapping delegate never sees, so IndexUpgrader (or UpgradeIndexMergePolicy) with a SoftDeletesRetentionMergePolicy inside silently purges the soft-deleted documents the retention policy must keep
Area
core index / UpgradeIndexMergePolicy and IndexUpgrader with OneMergeWrappingMergePolicy delegates (SoftDeletesRetentionMergePolicy)
Priority
Low, rank 315: Needs the unusual composition of soft-deletes retention with IndexUpgrader or UpgradeIndexMergePolicy, which the large soft-deletes users do not call.
Impact class
silent wrong results
Impact note
Applications that keep soft-deleted documents with SoftDeletesRetentionMergePolicy (for example as change history for replication or recovery) and upgrade the index format with new IndexUpgrader(dir, config, deletePriorCommits), or with UpgradeIndexMergePolicy wrapped around the retention policy as its javadoc recipe shows, lose every retained soft-deleted document of an old segment that the delegate declines to merge; with TieredMergePolicy as the delegate (the default) that is always the case on 7.5.0 and later for a lone old segment without hard deletes (the usual shape of a force-merged index) and also for a partially upgraded index that holds one such old segment (a LogMergePolicy delegate declines only a lone compound-file old segment, fact-check probe); the loss is permanent for the index (the upgrade keeps only its new commit) and silent, the visible documents (through readers that apply soft deletes) stay the same, it needs the opt-in soft-deletes retention plus the upgrade tool's config constructor (the command-line form cannot open a soft-deletes index at all), and no scale is needed.
Summary
You configure soft deletes with SoftDeletesRetentionMergePolicy("soft", MatchAllDocsQuery::new, new TieredMergePolicy()), which must keep every soft-deleted document, and upgrade a one-segment index written by Lucene 10.5.2 (10 visible plus 3 soft-deleted documents) with new IndexUpgrader(dir, thatConfig, false).upgrade(). You expect the segment to be rewritten in the current format with all 13 documents, exactly as happens when the same index has two old segments. Instead TieredMergePolicy treats the lone segment as already merged, UpgradeIndexMergePolicy rewrites it with its own 'merge remaining ones into new segment' OneMerge that the retention policy never wraps, and the 3 retained documents are purged with no error (maxDoc 13 -> 10; partial retention queries lose their documents the same way).
Introduced
02fa5a0b0593 (branch_7x; master ecc17f902330), LUCENE-8233 'Add support for soft deletes to IndexWriter' (JIRA patch, no GitHub PR; migrated issue apache/lucene#9280). It added SoftDeletesRetentionMergePolicy as a OneMergeWrappingMergePolicy whose OneMerge wrapper (wrapForMerge -> applyRetentionQuery) is the only thing that keeps retained soft-deleted documents through a merge, and nothing routes UpgradeIndexMergePolicy's own merge through it [read: git log, git blame and git log -S in the history clone; exec: 7.3.1 lacks the API (compile error), 7.4.0 loses the documents]. The defective line spec.add(new OneMerge(newInfos)) (UpgradeIndexMergePolicy.java:136) is older: LUCENE-3082 (600a50bb2ae8, 2011-05-09, first release 3.2). It has bypassed OneMergeWrappingMergePolicy wrapping since that class appeared (LUCENE-7688, 6.5.0) but lost no data before soft deletes [exec on 6.5.0 and 7.3.1 with a merge-counting wrapper]. LUCENE-7976 (branch_7x 9c4e315c1cb3, master 2519025fdafe, 7.5.0) widened the trigger from a lone non-compound-file old segment to any lone old segment without deletes [exec]. (2018-04-04 (LUCENE-8233 committed to branch_7x and master); the defective line 2011-05-09 (LUCENE-3082); trigger widened 2018-06-15 (LUCENE-7976))
First affected version
7.4.0 [exec, -ea and -da], the first release with soft deletes and SoftDeletesRetentionMergePolicy. On 7.4.0 the lone old segment must not be a compound file (a lone compound-file old segment is merged by TieredMergePolicy itself, wrapped, and kept [exec]), and the only IndexUpgrader trigger is an index written by an older release that started soft-deleting under 7.4.0 [exec]. 7.5.0 is the first release in which an ordinary upgrade of a soft-deletes index (written by 7.4.0) loses the documents, compound file or not [exec]. 7.3.1 and earlier: the API is absent [exec: compile error].
Affected versions
Every release from 7.4.0 to 10.6.0 (10.6.0 by reading), and main a9779ef3c193; never fixed. Executed (retain-all policy, one old segment without hard deletes, 13 -> 10 documents) on 7.4.0 (-ea and -da), 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da) with official release jars and JDK 25 [exec]; the two-old-segment control keeps all 13 documents on 7.4.0, 7.5.0, 8.0.0, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main [exec]; the releases in between share the unchanged code path [read]. The branch_10_6 head (10.6.0, released 2026-10-29) and the unreleased branch_10x head have the same code [read, not executed]. The 7 commits on main since the hunt's previous base do not touch any of the files involved. Not affected: 7.3.1 and earlier (no soft deletes) [exec].
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
non-formal: code reading
Upstream and prior reports
none found (2026-10-26 follow-up search, read-only, the prior-report record verdict new, 163 query records: 87 GitHub records (issue, PR and code searches over apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch and apache/solr, and a scan of the changed files of all 407 open apache/lucene PRs), 40 dev@, java-user@, issues@ and commits@ list queries, 15 JIRA queries (LUCENE and SOLR; full text of five issues read), 9 web, 7 commit-history and 4 downstream queries, and one record for the finder's earlier run (40 queries) and the intended-behaviour check's (27), which agree). No issue, PR, review thread, JIRA entry, list post or downstream report says that UpgradeIndexMergePolicy's own leftover merge skips a decorating delegate's OneMerge wrapping, or that an upgrade drops soft-deleted documents kept by SoftDeletesRetentionMergePolicy. Closest, same code block but a different defect: LUCENE-7523 (apache/lucene#8574, open: the leftover merge can build a monster segment), LUCENE-8004 (#9052, Won't Fix) and LUCENE-7671 (#8722, open); their proposals would still create unwrapped merges. LUCENE-8264 (#9311, Won't Fix) names UpgradeIndexMergePolicy, OneMergeWrappingMergePolicy and SoftDeletesRetentionMergePolicy together, but for N-2 index support. Open PR #16571 touches OneMergeWrappingMergePolicy and the retention wrapper (the LFV-509 overlap), not this path, and would not fix it. Not fixed: no commit on main or branch_10x changes this path, and no open PR touches UpgradeIndexMergePolicy.java.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): three public-API JUnits through gradle in a scratch worktree with assertions on and off (2026-10-27), and with two fixes together with 14 closest upstream test classes in core, backward-codecs and misc; repro runs by the finder, an independent reproducer, a devil's-advocate verifier, the adjudication, an audit and the registration check; the release jars 7.4.0, 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 upgrading indexes written by older releases, plus 6.4.2, 6.5.0 and 7.3.1 for the older root (59 history runs). OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/UpgradeIndexMergePolicy.java:26-50 (at a9779ef3c193) (class javadoc: 'All other methods delegate to the base MergePolicy' at :28, usage recipe iwc.setMergePolicy(new UpgradeIndexMergePolicy(iwc.getMergePolicy())) at :38, @lucene.experimental at :50), :80-139 (findForcedMerges: delegate first, then the log message 'does not want to merge all old segments, merge remaining ones into new segment' at :120-122 and spec.add(new OneMerge(newInfos)) at :136)
  • lucene/core/src/java/org/apache/lucene/index/OneMergeWrappingMergePolicy.java:23-28 (at a9779ef3c193) (wraps 'the OneMerge objects returned by the wrapped merge policy'), :52-59 (findForcedMerges -> wrapSpec(in.findForcedMerges(...))), :75-83 (wrapSpec)
  • lucene/core/src/java/org/apache/lucene/index/SoftDeletesRetentionMergePolicy.java:36-46 (at a9779ef3c193) (class javadoc: 'allows to carry over soft deleted documents across merges'; extends OneMergeWrappingMergePolicy)
  • lucene/core/src/java/org/apache/lucene/index/IndexUpgrader.java:137-148 (at a9779ef3c193) (default constructors use new IndexWriterConfig(null)), :159-163 (config constructor 'using the given config'), :180 (iwc.setMergePolicy(new UpgradeIndexMergePolicy(iwc.getMergePolicy()))), :181 (KeepOnlyLastCommitDeletionPolicy), :196 (forceMerge(1)), :204 (commit)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:6445-6456 (at a9779ef3c193) (numDeletesToMerge returns only hard deletes for an unpooled segment, 'this is safe!'), :5497-5500 (mergeMiddle keeps soft deletes only when wrapForMerge returns another reader)
  • lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:838-846 (at a9779ef3c193) (forceMerge(1) with a single eligible segment whose entry is in segmentsToMerge: 'already merged')
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:551-555 (at a9779ef3c193) (soft deletes retain documents across merges if the merge policy modifies the live docs of a merge reader); FieldInfos.java:459-466 (an IndexWriter without the soft-deletes field rejects a soft-deletes index)
  • github.com/apache/lucene/issues/8574, /9052, /8722, /9311 and pull/16571 (read 2026-10-26, via the prior-report record)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/UpgradeIndexMergePolicy.java:26-50, 68-70, 80-139
  • lucene/core/src/java/org/apache/lucene/index/OneMergeWrappingMergePolicy.java:23-83
  • lucene/core/src/java/org/apache/lucene/index/SoftDeletesRetentionMergePolicy.java:36-46
  • lucene/core/src/java/org/apache/lucene/index/IndexUpgrader.java:137-148, 154-163, 180-204
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:6445-6460, 5492-5501
  • lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:834-848
  • lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:459-466
  • lucene/core/src/java/org/apache/lucene/index/LogMergePolicy.java:195-202, 348-356; MergePolicy.java:745-758; FilterMergePolicy.java:50-53
Who is affected
  • lucene/MIGRATE.md:153
  • lucene/core/src/java/org/apache/lucene/index/IndexUpgrader.java:181, 204
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/SoftDeletesRetentionMergePolicy.java:36-44
  • lucene/core/src/java/org/apache/lucene/index/UpgradeIndexMergePolicy.java:26-50
  • lucene/core/src/java/org/apache/lucene/index/IndexUpgrader.java:31-53, 154-163
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:551-555
  • lucene/core/src/test/org/apache/lucene/index/TestUpgradeIndexMergePolicy.java:24-33
  • github.com/apache/lucene/issues/9311 and /8574 (read by the devil's-advocate verifier, 2026-10-26)
Suggested fix
  • lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:119-158

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

Repro files

About the diagrams

A step-by-step picture made in parallel with this doc from a probe run on main a9779ef3c193; every value on screen is taken from the probe's trace.

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.