All findings · Lucene finding · LFV-730 · softdeletes-merge-carryover-assumes-wrapper-only-revives

With soft deletes, a merge wrapper that drops documents breaks the merged segment's soft-delete count

With a soft-deletes field set, a merge policy whose wrapForMerge drops a live document (its javadoc allows it) makes forceMerge(1) throw invalid softDelCount=-1 and close the IndexWriter. Under SoftDeletesRetentionMergePolicy the merge stores 0 soft deletes where 1 is right, so counts say 3 for 2 live documents.

Summary

Priority
Medium, rank 166Since 7.7, core IndexWriter soft-delete accounting goes negative or too low when an in-contract merge wrapper hides live documents, which damages the index for good, but it needs soft deletes plus a custom document-dropping wrapper that no known user runs, so it closes the rare-config index-damage cluster after LFV-580.
Impact
silent wrong results
Since
7.7.0 (LUCENE-8569, 2018)introduced 2018-11-20 · about 7 years
Affected
7.7.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
a bounded-exhaustive test of merge-policy wrapper compositions

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

During a merge, the merge policy may wrap each segment's reader and hide documents. With soft deletes, IndexWriter must also record how many soft-deleted documents the new segment keeps. For a segment without hard deletes it subtracts instead of counting, which is right only if the wrapper never hides a live document.

Step-by-step diagram of a merge in an index with a soft-deletes field: segment 1 holds a and b, segment 2 holds c; the merge wrapper hides the expired document b; with no hard deletes IndexWriter computes softDelCount minus numDeletedDocs = 0 - 1 = -1 and the merge throws invalid softDelCount=-1 (maxDoc=2). The failure closes the IndexWriter: a retry throws AlreadyClosedException, and a reopened writer fails again and loses an uncommitted document. With SoftDeletesRetentionMergePolicy keeping a and assertions off, it computes 1 - 1 = 0 instead of 1: the merged segment stores 0 soft deletes, counts say 3 for 2 live documents, CheckIndex at level 2 fails and the next merge throws and closes the writer; with assertions on that merge fails at IndexWriter's assertion. Control: the same merge works without soft deletes or with one hard delete in segment 1.
Step by step through the executed repro: the wrapper hides b, the no-hard-deletes shortcut computes 0 - 1 = -1, the merge throws and the IndexWriter closes; with the retention policy and assertions off it stores 0 instead of 1; the same merge works without soft deletes or with one hard delete. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You set setSoftDeletesField("soft") and a merge policy whose OneMerge#wrapForMerge drops expired documents; its javadoc says a wrapper may "add/remove fields or documents". Segment 1 holds a and b (expired), segment 2 holds c. Nothing is soft-deleted.
  2. You call forceMerge(1) and expect one segment with a and c, which is what the same code gives without a soft-deletes field.
  3. For a segment without hard deletes, IndexWriter works out the soft deletes to keep as softDelCount minus the wrapped reader's numDeletedDocs: 0 - 1 = -1, because the dropped live document b counts as a removed soft delete. The merge throws invalid softDelCount=-1 (maxDoc=2), and as a tragic event it closes the IndexWriter: uncommitted changes are lost and every later call throws AlreadyClosedException.
  4. Put SoftDeletesRetentionMergePolicy on top and keep one soft-deleted document (a, replaced by a2): the sum is 1 - 1 = 0 instead of 1, and with assertions off it is committed. A search finds 2 documents, but numDocs and IndexSearcher#count say 3.
  5. CheckIndex at level 2 reports actual soft deletes: 1 but expected: 0, and once a is no longer retained the next merge throws invalid softDelCount=-1 (maxDoc=3) and closes the writer. A segment with one unrelated hard delete is counted properly and merges fine.

Who is affected

Applications that set a soft-deletes field and install their own merge wrapper that hides live documents, for example to expire documents at merge time. No in-tree policy does this, so default configurations are safe. For those applications it is deterministic on every merged segment without hard deletes: the merge fails and closes the IndexWriter, or a too-low count is stored.

Mechanism, terms and numbers1,018 words

OneMerge#wrapForMerge is documented as the place to "add/remove fields or documents" before a merge, for example to drop expired documents. When the index also has a soft-deletes field, the merge must record how many soft-deleted documents the new segment still holds. For a wrapped segment with hard deletes, IndexWriter.mergeMiddle counts them; for one without hard deletes it subtracts, getSoftDelCount() - wrappedReader.numDeletedDocs(), which is right only if the wrapper never hides a live document. With nothing soft-deleted, a wrapper that drops one expired document gives -1 and forceMerge(1) throws invalid softDelCount=-1 (maxDoc=2); the failure is a tragic event that closes the IndexWriter and rolls back its uncommitted changes, and a reopened writer fails the same way. Under SoftDeletesRetentionMergePolicy with one retained soft-deleted document the merge succeeds (assertions off) but records 0 soft deletes instead of 1: numDocs, IndexSearcher#count and getDocStats().numDocs say 3 while 2 documents are live, CheckIndex at level 2 reports actual soft deletes: 1 but expected: 0, and once the document is no longer retained the next merge of that segment throws invalid softDelCount=-1 (maxDoc=3) and closes the writer. Searches through the writer's readers stay right. The same merge works without a soft-deletes field, or when the segment happens to hold one unrelated hard delete. The shortcut came with LUCENE-8569 (2018); releases 7.4.0-7.6.0 counted correctly, every release from 7.7.0 to 10.6.0 (10.6.0 by reading) and main is affected. It needs the application's own document-dropping merge wrapper, so default configurations are not affected.

Terms

  • Soft deletes: with IndexWriterConfig.setSoftDeletesField("soft"), softUpdateDocument (or updateDocValues on that field) hides a document by giving it a doc value in the soft-deletes field instead of marking it deleted. Each segment stores how many such documents it holds (softDelCount); the readers the writer hands out compute numDocs as maxDoc - delCount - softDelCount.
  • Merge wrapper: MergePolicy.OneMerge#wrapForMerge may wrap each segment's reader before the merge "in order to add/remove fields or documents" (MergePolicy.java:301-309); OneMergeWrappingMergePolicy is the stock way to install one. A wrapper that returns fewer live documents drops them from the merged segment.
  • SoftDeletesRetentionMergePolicy: a wrapper that does the opposite, it revives soft-deleted documents that match a retention query so they survive the merge.

The bug

  1. IndexWriter.mergeMiddle wraps each reader (IndexWriter.java:5497). With soft deletes and a wrapped reader (:5499-5500) it must compute the soft deletes that survive.
  2. With hard deletes it counts. If the segment has hard deletes (:5504), countSoftDeletes (:5427-5452) walks the soft-deletes doc values and counts the documents still live in the wrapped reader. Right for any wrapper.
  3. Without hard deletes it subtracts (:5530-5535): getSoftDelCount() - wrappedReader.numDeletedDocs(), with the assertions "carry-over soft-deletes must be positive" and a recount check. If the segment has s soft-deleted documents and the wrapper revives r and hides k live ones, numDeletedDocs is s - r + k, so the result is r - k instead of r. Right only when k = 0.
  4. The total is stored with merge.info.setSoftDelCount(...) (:5593). A negative total throws IllegalArgumentException("invalid softDelCount=...") (SegmentCommitInfo.java:384-388); a too-low total is committed. Only the total is checked at run time: with assertions off, one segment's negative carry-over can hide behind another segment's positive count.
  5. A failed merge closes the writer. The exception escapes IndexWriter.merge, whose catch (Throwable t) calls tragicEvent(t, "merge") (IndexWriter.java:5109-5113); maybeCloseOnTragicEvent then runs rollbackInternal (:6098-6105). The writer drops everything since its last commit and closes; every later call throws AlreadyClosedException. Under the default ConcurrentMergeScheduler the merge thread does the same and forceMerge(1) throws IllegalStateException: this writer hit an unrecoverable error; cannot complete forceMerge (:2286-2289).
  6. What reads it. In the writer's readers (DirectoryReader.open(IndexWriter)) and IndexWriter#getDocStats(), numDocs is derived from the stored count (PendingDeletes.java:242-250), and IndexSearcher#count(MatchAllDocsQuery) returns numDocs per leaf (search/MatchAllDocsQuery.java:57-58), while live docs are rebuilt from the doc values, so their searches stay right. A SoftDeletesDirectoryReaderWrapper over a directory reader recounts from the doc values (SoftDeletesDirectoryReaderWrapper.java:142-147) and, with assertions off, reports the right count. CheckIndex compares the stored and the counted value (CheckIndex.java:4941-4947) only at level 2 or higher (:1114); neither the command-line default (level 1) nor an API CheckIndex without setLevel (level 0) runs that check.
  7. The next merge. Once the retention query stops matching, the wrapper hides the soft-deleted document again and the shortcut computes 0 - 1 = -1 from the too-low stored count: that merge throws and closes the writer.

The numbers (main, assertions off)

scenarioexpectedgot
soft-deletes field, nothing soft-deleted, wrapper drops b; forceMerge(1)a, cIllegalArgumentException: invalid softDelCount=-1 (maxDoc=2); the IndexWriter closes itself, a retry throws AlreadyClosedException
a new IndexWriter on that index, add u (not committed), forceMerge(1)a, c, uinvalid softDelCount=-1 (maxDoc=3); the writer closes again and u is lost
control: no soft-deletes fielda, ca, c
control: segment 1 also holds one unrelated hard deletea, ca, c
retention keeps a (replaced by a2), wrapper drops bsoftDelCount 1, count 2softDelCount 0; search 2 hits, count, numDocs, getDocStats().numDocs 3
CheckIndex on that index, level 1 / level 2clean / cleanclean / actual soft deletes: 1 but expected: 0
retention ends, add d, forceMerge(1)c, a2, dinvalid softDelCount=-1 (maxDoc=3); the writer closes

With assertions on, every bug row fails at IndexWriter's own assertion instead (carry-over soft-deletes must be positive, or soft-deletes count mismatch expected: 0 but actual: 1), which closes the writer too. A bounded-exhaustive run on the real code covers 584 combinations (segments of 1-3 documents, each live, soft-deleted or hard-deleted, and a wrapper that keeps, removes or revives each one; reviving a hard-deleted document is excluded, so 584 of 819). It fails in exactly the 103 cases where the segment has no hard deletes and the wrapper removes a live document, and in none of the other 481. With assertions off 74 of the 103 throw and 29 commit a wrong count; with assertions on all 103 throw.

Exposure and likelihood279 words
  • Who. Applications that set a soft-deletes field and install their own merge policy whose OneMerge#wrapForMerge hides live documents, for example dropping expired documents at merge time through OneMergeWrappingMergePolicy (@lucene.experimental), alone or under SoftDeletesRetentionMergePolicy. The production overrides of wrapForMerge in Lucene revive (SoftDeletesRetentionMergePolicy) or delegate (IndexWriter, misc's BPReorderingMergePolicy), so default configurations are not affected.
  • Trigger. Deterministic whenever a merge covers a segment without hard deletes and the wrapper hides at least one live document in it; the introducing commit calls segments without hard deletes "the common case". No scale, concurrency or special data.
  • Effect. Loud form: the merge throws and, as a tragic event, closes the IndexWriter: uncommitted changes are rolled back, every later call throws AlreadyClosedException, and a reopened writer closes again on its next merge of the same segments (also under the default ConcurrentMergeScheduler). Silent form (assertions off, as in production, with a retention policy): the committed segment records too few soft deletes; the writer's document counts over-count by the number of hidden documents, CheckIndex at level 2 or higher reports corruption, and the next merge of that segment throws and closes the writer once the retained documents are no longer retained. Searches through the writer's readers still return only live documents; no committed document is lost or revived. With assertions on, every form fails at IndexWriter's own assertion, which closes the writer too.
  • How rare. A judgement: the combination is uncommon. Apache Solr main has a wrapForMerge that drops documents but does not use soft deletes; GitHub searches of Elasticsearch, OpenSearch and Solr for the symptom strings return nothing; other downstream merge policies were not surveyed.

Minimal repro

The test shows that with a soft-deletes field configured, a merge whose OneMerge#wrapForMerge hides one live document (segments {a, b} and {c}, no hard deletes, b hidden) fails, because IndexWriter derives the carried-over soft deletes as getSoftDelCount() - numDeletedDocs() = -1.

TestSoftDeletesMergeWrapperDropsDoc.java (JUnit test)100 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.StringField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.Bits;
import org.apache.lucene.util.FixedBitSet;

public class TestSoftDeletesMergeWrapperDropsDoc extends LuceneTestCase {

  public void testMergeWrapperDropsLiveDoc() throws IOException {
    IndexWriterConfig iwc =
        new IndexWriterConfig()
            .setSoftDeletesField("soft")
            .setCodec(TestUtil.getDefaultCodec())
            .setMergeScheduler(new SerialMergeScheduler())
            .setMergePolicy(
                new OneMergeWrappingMergePolicy(
                    new LogDocMergePolicy(),
                    merge ->
                        new MergePolicy.OneMerge(merge.segments) {
                          @Override
                          public CodecReader wrapForMerge(CodecReader reader) throws IOException {
                            return dropDoc(reader, "b");
                          }
                        }));
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      w.addDocument(doc("a"));
      w.addDocument(doc("b"));
      w.commit();
      w.addDocument(doc("c"));
      w.commit();
      // expected: the merge drops b and keeps a, c; actual: it throws (merged softDelCount is -1)
      w.forceMerge(1);
      assertEquals(2, w.getDocStats().numDocs);
    }
  }

  private static Document doc(String id) {
    Document doc = new Document();
    doc.add(new StringField("id", id, Field.Store.NO));
    return doc;
  }

  private static CodecReader dropDoc(CodecReader reader, String id) throws IOException {
    PostingsEnum postings = reader.postings(new Term("id", id));
    int drop = postings == null ? -1 : postings.nextDoc();
    Bits liveDocs = reader.getLiveDocs();
    FixedBitSet live = new FixedBitSet(reader.maxDoc());
    for (int doc = 0; doc < reader.maxDoc(); doc++) {
      if (doc != drop && (liveDocs == null || liveDocs.get(doc))) {
        live.set(doc);
      }
    }
    int numDocs = live.cardinality();
    return new FilterCodecReader(reader) {
      @Override
      public Bits getLiveDocs() {
        return live;
      }

      @Override
      public int numDocs() {
        return numDocs;
      }

      @Override
      public CacheHelper getCoreCacheHelper() {
        return in.getCoreCacheHelper();
      }

      @Override
      public CacheHelper getReaderCacheHelper() {
        return null;
      }
    };
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-730/, 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.TestSoftDeletesMergeWrapperDropsDoc
Expected vs actual
Expected forceMerge(1) to drop b and keep a and c; actual: AssertionError: carry-over soft-deletes must be positive with -ea, IllegalArgumentException: invalid softDelCount=-1 (maxDoc=2) with -da.

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

More files, environment, full output40 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: carry-over soft-deletes must be positive
       org.apache.lucene.index.IndexWriter.mergeMiddle(IndexWriter.java:5533)
       org.apache.lucene.index.IndexWriter.merge(IndexWriter.java:5081)
       org.apache.lucene.index.IndexWriter$IndexWriterMergeSource.merge(IndexWriter.java:6884)
       org.apache.lucene.index.SerialMergeScheduler.merge(SerialMergeScheduler.java:38)
       org.apache.lucene.index.IndexWriter.executeMerge(IndexWriter.java:2451)
       org.apache.lucene.index.IndexWriter.maybeMerge(IndexWriter.java:2446)
       org.apache.lucene.index.IndexWriter.forceMerge(IndexWriter.java:2281)
       org.apache.lucene.index.IndexWriter.forceMerge(IndexWriter.java:2229)
       org.apache.lucene.index.TestSoftDeletesMergeWrapperDropsDoc.testMergeWrapperDropsLiveDoc(TestSoftDeletesMergeWrapperDropsDoc.java:56)

Why it looks unintended

  • wrapForMerge may "add/remove fields or documents"; its only stated restriction is no doc-ID reordering.
  • Same wrapper, same documents: it works without a soft-deletes field, or with one unrelated hard delete in the segment.
  • Releases 7.4.0-7.6.0 counted correctly, and the review of the 2018 shortcut called a negative result "a bug".
The full argument213 words
  • Contracts. OneMerge#wrapForMerge: "Wrap a reader prior to merging in order to add/remove fields or documents"; only reordering doc IDs is illegal (MergePolicy.java:301-309, wording since 9.9.0 via #12622; before: "add/remove information to the merged segment"). setSoftDeletesField: "Soft deletes allow to retain documents across merges if the merge policy modifies the live docs of a merge reader" (IndexWriterConfig.java:551-552). IndexWriter's own comment: "this is also required if any MergePolicy modifies the liveDocs" (IndexWriter.java:4844-4845). CheckIndex treats a mismatching count as corruption.
  • The inconsistency. No soft-deletes field: works. Soft-deletes field plus one unrelated hard delete in the segment: works (the counting path). Soft-deletes field, no hard delete: fails. A deliberate rule would not depend on an unrelated hard delete.
  • History. 7.4.0-7.6.0 counted the surviving soft-deleted documents of every wrapped segment. LUCENE-8569 (#9615, apache/lucene-solr#502, 2018) replaced that with the subtraction as an optimization that gives "accurate counts"; neither the issue nor the PR mentions wrappers that hide live documents. In that review the negative-carry-over check was an IllegalStateException; a reviewer asked "AssertionError instead? This means we have a bug right?", and it became the assert: a negative result was treated as an IndexWriter bug, not as misuse.
  • Tests. No upstream test removes documents in wrapForMerge in a soft-deletes index.
The devil's-advocate check236 words

The intended-behaviour verifier and a cross-model review built the strongest case for "not a bug": the merge-policy hooks are expert API and OneMergeWrappingMergePolicy is @lucene.experimental; every in-tree wrapper only revives; the shortcut's assertions show its author expected revive-only wrappers; and the setSoftDeletesField sentence can be read as "with soft deletes, a merge policy changes live docs only to retain documents".

Why it fails: no document states a revive-only rule and wrapForMerge explicitly allows removing documents; the setSoftDeletesField sentence names retention "for instance" and restricts nothing; the review of the introducing change called a negative carry-over a bug; the outcome flips on an unrelated hard delete; 7.4.0-7.6.0 handled the same wrapper correctly; @lucene.experimental permits API changes, not a merge that stores a wrong count or closes the writer; and IndexWriter treats the outcome as a tragic event, not as a cleanly rejected policy. Indexing every document with softUpdateDocument, as setSoftDeletesField asks, fails the same way, so it is not misuse. Both rated the case weak. Verdict: a defect.

Points a maintainer may still raise (a cross-model fact check found the rebuttal fair but incomplete here): the explicit "add/remove fields or documents" wording came in 9.9.0, five years after the shortcut; the usual way to remove documents is a delete; nobody reported it in eight years; and LUCENE-8569 had a real performance motive. None of them makes a closed writer or a wrong stored count acceptable.

Suggested fix

Recommended Count the surviving soft deletes in both branches

Count the soft-deleted documents that stay live in the wrapped reader for every wrapped segment, as segments with hard deletes (and releases before 7.7.0) already do: one change in IndexWriter.mergeMiddle, +9/-14. The repros pass with assertions on and off; the full lucene/core suite (8,899 tests) passes with assertions on.

} else {
  // The wrapper may revive soft-deleted documents and may also remove documents
  // (OneMerge#wrapForMerge), so the carried-over soft deletes cannot be derived from
  // the deletion counts alone: count the soft-deleted documents that stay live.
  countSoftDeletes(
      wrappedReader, wrappedReader.getLiveDocs(), null, softDeleteCount, Counter.newCounter(false));
}
All options and trade-offs4 options ยท 623 words

One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF). The other options are sketches, not run. Which one, or another design, is the maintainers' call.

Count the surviving soft deletes in both branches (validated by the checks) (recommended)

Replace the subtraction in the no-hard-deletes branch with countSoftDeletes, the counting the hard-deletes branch uses and that 7.4.0-7.6.0 used for every wrapped segment, and delete assertSoftDeletesCount, which becomes unused (the ECJ lint settings make an unused private member an error). Patch: fix-count-soft-deletes.diff, +9/-14 in IndexWriter.java. Equivalent by reading: drop the hardLiveDocs != null split and always call countSoftDeletes with hardLiveDocs, as 7.6.0 did.

} else {
  // The wrapper may revive soft-deleted documents and may also remove documents
  // (OneMerge#wrapForMerge), so the carried-over soft deletes cannot be derived from
  // the deletion counts alone: count the soft-deleted documents that stay live.
  countSoftDeletes(
      wrappedReader, wrappedReader.getLiveDocs(), null, softDeleteCount, Counter.newCounter(false));
}

Trade-offs

  • It gives back the LUCENE-8569 optimization for wrapped segments without hard deletes: one pass over the soft-deletes doc values of each such segment (only documents that carry a soft-delete value, one Bits.get each). Segments that are not wrapped still skip all accounting.
  • For SoftDeletesRetentionMergePolicy, the wrapper of such a segment already runs a query over its soft-deleted documents and copies its live docs (SoftDeletesRetentionMergePolicy.java:97-130), so the extra pass is of the same order (by reading). No benchmark was run.

What the checks validated

  • Stock: the compact test and the finder's test together run 10 tests: 6 failed and 1 skipped with assertions on, 7 failed with assertions off.
  • With the fix: 10 of 10 pass with assertions on and with assertions off.
  • With the fix: the full lucene/core test suite passes (750 classes, 8,899 tests, 322 skipped, 0 failures, assertions on, including both repro classes and the soft-delete test classes); :lucene:core:ecjLint and :lucene:core:checkGoogleJavaFormat pass.
  • Earlier, with the same change shadowed: the 584-case exhaustive run has 0 failures (assertions on and off); TestSoftDeletesRetentionMergePolicy (14 tests) and TestSoftDeletesDirectoryReaderWrapper (5 tests) pass over 3 iterations each; the independent reproducer's 6 tests pass.

Keep the shortcut for wrappers that declare they only revive (not run)

No cheap runtime check can tell "revives r, hides k" from "revives r - k, hides nothing": both give the same numDeletedDocs. So the shortcut would need the wrapper to declare that it only revives, for example through a new OneMerge method that SoftDeletesRetentionMergePolicy overrides.

Trade-offs

  • New API surface; a chain such as retention over a user wrapper needs every link to declare it; the saving is small by the trade-off above.
  • A generic runtime test ("the wrapped live docs are a superset of the original ones") is no cheaper: it costs a pass over maxDoc and does not cover a wrapper that changes the soft-deletes field itself, which wrapForMerge also permits.

Document a revive-only rule and fail with a clear message (not run)

Turn the assertion into an explicit exception and document that wrappers in soft-deletes indexes must not hide live documents.

Trade-offs

  • Contradicts the wrapForMerge javadoc and rejects merges that work without a soft-deletes field; it still fails inside the merge, so the writer still closes; a too-low but non-negative count can only be detected by counting, which is the first option's cost.

Workaround today: hard-delete expired documents instead of hiding them (not run)

Drop expired documents with IndexWriter#deleteDocuments(Query) instead of hiding them in wrapForMerge; a segment with hard deletes takes the counting path. The delete must already be applied when the merge takes its readers: the branch is chosen from the merge reader's hard-live-docs snapshot (ReadersAndUpdates.java:1092-1108).

Trade-offs

  • By reading only; it changes when the documents disappear (at the delete, not at the merge).

Downstream note

Apache Solr main ships a merge wrapper that drops documents (AuxIndexJoinMergePolicy, apache/solr#4749) but does not use soft deletes, so it is not affected; other downstream merge policies were not surveyed.

How it was found

A bounded-exhaustive test that composes merge-policy wrappers checked its own oracle with a deliberately broken wrapper that drops one live document; in every cell IndexWriter's assertion "carry-over soft-deletes must be positive" fired instead. Reading mergeMiddle found the shortcut, and a 584-case exhaustive run on the real code showed exactly when it fails.

The method in full185 words

Method: a bounded-exhaustive differential and model-based test (semi-formal tier) of merge-policy wrapper compositions: 50 wrapper towers x 5 base policies x 10 triggers x 3 data profiles x 2 seeds, checked against a model oracle.

  • To check that its oracle can fail, the harness runs mutants. The mutant that wraps merges to drop one live document was meant to be caught by the model's live-documents check; instead IndexWriter's own assertion "carry-over soft-deletes must be positive" fired in every cell.
  • Reading IndexWriter.mergeMiddle located the no-hard-deletes subtraction.
  • A second bounded-exhaustive run on the real code (584 combinations, reviving a hard-deleted document excluded: segments of 1-3 documents, each live, soft-deleted or hard-deleted, and a wrapper that keeps, removes or revives each) failed in exactly the 103 cases with no hard deletes and a removed live document, and in 0 of 481 others; with the fix, 0 of 584.
  • Found 2026-10-27 on main a9779ef3c193; then checked by an independent reproducer, a devil's-advocate verifier, reviews by a model from another family (OpenAI GPT-5.6) that ran their own tests, an adjudication, a follow-up (history, prior reports, security) and an audit.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27): GitHub issues, PRs and commits, JIRA, the mailing lists, Elasticsearch, OpenSearch and Solr, and the changed files of all 410 open PRs. Open PR #16571 avoids the same shortcut in its new merge path, with a comment saying why, but does not report or change this one.

Searches4 searches

Verdict new, with one judgement call (#16571). Searches cannot prove absence.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits2026-10-27four runs of the hunt's prior-report tool plus manual searches: 56 issue and PR queries and 6 commit queries (class and method names, every symptom string, LUCENE-8569, phrasings such as "drop documents at merge time")no report; origin: LUCENE-8569 / #9615 and apache/lucene-solr#502; related, other paths: #9428 (LUCENE-8381), #9504, #9340, #9431; #16847 only renames a method on that line
Open apache/lucene PRs2026-10-27changed files of all 410 open PRs; the 16 that touch IndexWriter.java grepped for the carry-over code#16571 (open): its partitioned-merge path counts with countSoftDeletes because "the single-output shortcut (softDelCount - numDeletedDocs) cannot be used here"; adjacent, it leaves the existing path unchanged.
JIRA archive; dev@, java-user@, issues@, commits@2026-10-2716 JIRA queries and 72 list queriesno report
Elasticsearch, OpenSearch, Solr; the web2026-10-2712 downstream queries (exact symptom strings) and 6 web queriesno report

History

Introduced by LUCENE-8569 (2018), an optimization that replaced counting with the subtraction: first released in 7.7.0 (backport) and 8.0.0. Executed: 7.4.0-7.6.0 pass, every executed release from 7.7.0 to 10.5.2 and main fail, and reverting only that change makes 7.7.0 and 8.0.0 pass. Never fixed.

Versions tested6 rows

Introduced by LUCENE-8569 "Never count soft-deletes if reader has no hard-deletes" (@s1monw, apache/lucene-solr#502, migrated issue #9615): main line 56cb42d200dd (2018-11-20/21, first released in 8.0.0) and the branch_7x backport 8f76c1b0ca64 (first released in 7.7.0). Before it, mergeMiddle counted the surviving soft-deleted documents of every wrapped reader (7.4.0 with the LUCENE-8341 backport 21f03a49532, 7.5.0 and 7.6.0 with the LUCENE-8381 backport c65fb8568a9). Soft deletes first shipped in 7.4.0 (LUCENE-8233), so no earlier release can build the trigger.

Releases not executed (7.7.1-7.7.2, 8.1.0-8.11.3, 9.1.0-9.12.2, 10.1.0-10.5.1) are affected by the shortcut's assertion string in IndexWriter.class of every release jar from 7.7.0 to 10.5.2 (absent in 7.4.0-7.6.0) and, except 8.8.2-8.11.x (not tagged in the history clone), by tag containment. Upstream main a95c754ee003 and branch_10x still have the subtraction (read, not run).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedcompact JUnit 4 of 6 fail through gradle, assertions on and off; controls pass
10.0.0, 10.5.2, 9.12.3 affectedinvalid softDelCount=-1; retention: count 3 for 2 live; next merge throws
7.7.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0 affectedinvalid softDelCount=-1 (maxDoc=2); retention numDocs 3 for 2 live
7.4.0, 7.5.0, 7.6.0 not affectedpass: every wrapped segment is counted
7.7.0 and 8.0.0 with only LUCENE-8569 reverted bisectpass with assertions on and off
fix (scratch worktree at a9779ef3c193) not affectedrepros 10 of 10 pass, assertions on and off

Independent checks

An independent reproducer, a devil's-advocate verifier and reviews by a model from another family (OpenAI GPT-5.6) each reproduced it with their own tests; an adjudication confirmed it with high confidence, and the registration check and an audit re-ran the tests and kept it.

Checks and verdicts409 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 public-API test outside org.apache.lucene: 4 of 6 fail on main, 10.5.2 and 9.12.3 with assertions on and off; both controls pass; 6 of 6 pass with the fix. Corrected the claim: on 10.x and main, CheckIndex's default level does not flag it, level 2 does.
devil's advocate real; no intended or misuse argument holdsProbe indexing every document with softUpdateDocument: passes with one unrelated hard delete, fails without it, on main and 10.5.2. Found the LUCENE-8569 review remark and Solr's document-dropping wrapper.
cross-model reviews (refute and intended lenses) real, high (both)An independent reviewer from another model family (OpenAI GPT-5.6) wrote and ran its own tests in each lens, on the earlier main ea57360912ed (its IndexWriter, SegmentCommitInfo and SoftDeletesRetentionMergePolicy are identical to a9779ef3c193): they fail there and pass with the fix; the intended-lens test also fails on 10.5.2 and 7.7.0, and its notes record a pass on 7.6.0. Root cause agreed; the intended-behaviour case rated weak. The refute lens did not re-run the retention variant.
adjudication confirmed, highRe-ran the independent reproduction's test with a new seed: 4 of 6 fail with assertions on and off; the controls pass.
Registration check and audit reproduces; keepRe-ran the independent reproduction's test (new seeds), the finder's test and the intended-behaviour check's probe on stock main (same failures) and the independent reproduction's test with the fix (6 of 6 pass).
this doc's gradle runs reproduces; fix validatedCompact JUnit and the finder's test through gradle on stock main (fail) and with the fix (pass), assertions on and off. With the fix: the full lucene/core test suite passes (750 classes, 8,899 tests, 322 skipped, 0 failures, assertions on, including both repro classes and the soft-delete test classes); :lucene:core:ecjLint and :lucene:core:checkGoogleJavaFormat pass.
fact checks of this doc bug holds; 2 + 5 errors fixedAn independent fact check re-ran the gradle commands, the fix and the full lucene/core suite (same results) and found two errors, both fixed: the failed merge closes the IndexWriter (it is not a retryable exception), and one reader citation. A cross-model fact check (OpenAI GPT-5.6) found five smaller errors, fixed, and rejected one of its own counts. A diagram check found one unqualified assertions-off outcome, fixed and re-rendered.

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

Finding record

LFV-730, snapshot of 2026-11-06

Registry title
With a soft-deletes field configured, IndexWriter's merge assumes OneMerge#wrapForMerge only revives documents: for each merged segment without hard deletes, every live document that a merge wrapper hides (which the javadoc allows) lowers the new segment's soft-delete count by one, so the merge throws 'invalid softDelCount=-1' when nothing is revived, and under SoftDeletesRetentionMergePolicy commits a too-low count (numDocs and IndexSearcher#count over-count, CheckIndex rejects it, later merges of it throw); regression since 7.7.0 (LUCENE-8569)
Area
index / IndexWriter merge soft-delete accounting (mergeMiddle carry-over of soft deletes x OneMerge#wrapForMerge, OneMergeWrappingMergePolicy, SoftDeletesRetentionMergePolicy)
Priority
Medium, rank 166: Since 7.7, core IndexWriter soft-delete accounting goes negative or too low when an in-contract merge wrapper hides live documents, which damages the index for good, but it needs soft deletes plus a custom document-dropping wrapper that no known user runs, so it closes the rare-config index-damage cluster after LFV-580.
Impact class
silent wrong results
Impact note
Only applications that set a soft-deletes field and install their own merge policy whose OneMerge#wrapForMerge hides live documents (for example dropping expired documents at merge time through the @lucene.experimental OneMergeWrappingMergePolicy; no in-tree policy does this, so default configurations are unaffected) are hit, deterministically and at any index size, whenever a merge covers a segment that has no hard deletes and the wrapper hides a live document in it: the merged segment's soft-delete count comes out too low by the number of such hidden documents. With -ea the merge fails at IndexWriter's assertion 'carry-over soft-deletes must be positive' as soon as one segment's carry-over is negative; with -da (as in production) the merge throws IllegalArgumentException 'invalid softDelCount=-1' when the total is negative (for example nothing soft-deleted), and because the arithmetic is deterministic a retry of the same merge fails the same way [read]; when soft-deleted documents are retained, as with SoftDeletesRetentionMergePolicy over such a wrapper, the merge instead commits the too-low softDelCount silently, so DirectoryReader#numDocs, IndexSearcher#count(MatchAllDocsQuery) and IndexWriter#getDocStats().numDocs over-count while searches still return only the live documents, CheckIndex at level 2 or higher reports 'actual soft deletes: 1 but expected: 0', and once the retention query stops matching the next merge of that segment throws 'invalid softDelCount=-1 (maxDoc=3)'; every release from 7.7.0 to 10.5.2 and main [exec] and, by reading, 10.6.0.
Summary
You set IndexWriterConfig#setSoftDeletesField and a merge policy whose OneMerge#wrapForMerge hides expired documents (its javadoc says a wrapper may 'add/remove fields or documents'), index two segments without hard deletes, and call forceMerge(1); you expect the merge to drop the expired document and succeed, as the same merge does without a soft-deletes field, or with one when the segment happens to hold a hard delete. Instead IndexWriter derives the carried-over soft deletes as getSoftDelCount() - wrappedReader.numDeletedDocs(), which counts each hidden live document as a dropped soft delete, so the count comes out -1 and the merge throws IllegalArgumentException 'invalid softDelCount=-1 (maxDoc=2)'. With SoftDeletesRetentionMergePolicy on top (one retained soft-deleted document) the merge silently commits softDelCount=0, so numDocs and IndexSearcher#count report 3 where 2 documents are live, CheckIndex reports 'actual soft deletes: 1 but expected: 0', and after the retention window the next merge of that segment throws 'invalid softDelCount=-1 (maxDoc=3)'.
Introduced
56cb42d200dd999342c4cd5c92fbfa271ba91153 on the main line (LUCENE-8569 'Never count soft-deletes if reader has no hard-deletes', apache/lucene-solr#502, migrated issue apache/lucene#9615, by s1monw, approved by mikemccand and jpountz; first released in 8.0.0) and its branch_7x backport 8f76c1b0ca64cd65e05330de9379fed44cfe6a8f (first released in 7.7.0) [exec: commit isolation, 7.7.0 and 8.0.0 jars with only IndexWriter replaced by that release's source minus this commit pass 3 of 3 with -ea and -da, the stock jars fail 2 of 3; read: git blame with .git-blame-ignore-revs and git log -L in the full-history clone list only this commit plus the LUCENE-9570 reformat 2d6ad2fee6d]. Before it (7.4.0 to 7.6.0) mergeMiddle counted the surviving soft-deleted documents of every wrapped reader (code from LUCENE-8341 fe83838ec37 and LUCENE-8381 0ccd5d580d1), which is right for any wrapper (2018-11-20 (authored; committed 2018-11-21; the branch_7x backport the same days))
First affected version
7.7.0 [exec] (branch_7x backport); 8.0.0 [exec] is the first affected main-line release; 7.4.0, 7.5.0 and 7.6.0 pass [exec]; soft deletes first shipped in 7.4.0 (LUCENE-8233), so no earlier release can build the trigger
Affected versions
Every release from 7.7.0 through 10.6.0 (7.7.0-7.7.3 and every 8.x, 9.x and 10.x release; 10.6.0 by reading) and main a9779ef3c193 (unreleased 11.0); never fixed. [exec] 7.7.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main with -da, and 7.7.0, 8.0.0, 9.12.3, 10.5.2 and main with -ea: the same failures everywhere, the control passes (repro/src-pre9 variant before 9.1.0). [read] the other releases by tag containment of 8f76c1b0ca6 or 56cb42d200d and by the shortcut's assertion string in IndexWriter.class of every release jar 7.7.0-10.5.2 (absent in 7.4.0-7.6.0); 8.8.2-8.11.4 were released from the old lucene-solr branch_8x, covered by the bytecode marker and the 8.11.4 run. Upstream main a95c754ee003 (2026-10-26), branch_10x 657014b3c579 and branch_10_6 still contain the subtraction [read, not executed]. Executed on aarch64 Linux with JDK 25 only
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check; also reached by code reading
Upstream and prior reports
none found (searched 2026-10-27, read-only; the prior-report record, verdict new): the finder's 51-query scripted prior-report search plus three follow-up scripted prior-report searches and manual searches (totals in the prior-report record: 56 GitHub apache/lucene queries, 6 commit queries, 12 downstream queries over Elasticsearch, OpenSearch and Solr, 72 mailing-list queries over dev@, java-user@, issues@ and commits@, 16 JIRA queries, 6 web queries, and the changed files of all 410 open apache/lucene PRs). No issue, PR, JIRA entry, list thread or downstream report says that mergeMiddle's no-hard-deletes soft-delete carry-over is wrong when OneMerge#wrapForMerge hides live documents, and none of the symptom strings ('invalid softDelCount', 'carry-over soft-deletes must be positive', 'soft-deletes count mismatch', CheckIndex 'actual soft deletes: N but expected: M') appears in a report. Origin, not a report: LUCENE-8569 (#9615, closed 2018) and apache/lucene-solr#502, whose review turned the negative carry-over IllegalStateException into an assert ('This means we have a bug right?'). Adjacent: open PR #16571 (jimczi, 'Let a merge write more than one segment') counts soft deletes with countSoftDeletes in its new partitioned-merge path, with a code comment that the single-output shortcut cannot be used there because numDeletedDocs on a range-restricted reader also counts documents of the other outputs; it states the shortcut's precondition for its own reader but neither reports nor changes the existing path for user wrappers. Related, different paths: #9428 (LUCENE-8381, the hard-deletes branch, which counts correctly), #9504 (LUCENE-8458), #9340 (LUCENE-8293), #9431 (LUCENE-8384); #16847 (open draft) only renames numDeletedDocs on that line. Not fixed upstream: the subtraction is unchanged on main a95c754ee003 and branch_10x 657014b3c579. Searches cannot prove absence (GitHub search returns the first 50 results per query; two broad queries were truncated and their plausible titles read)
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): a compact public-API JUnit and the finder's JUnit through gradle in a scratch worktree, stock and with the fix, with assertions on and off, and the full lucene/core test suite with the fix (2026-10-27); repro runs by the finder, an independent reproducer, a devil's-advocate verifier, cross-model reviews (on the earlier main ea57360912ed, identical in the files involved), the adjudication, the registration check and an audit; a diagram probe of the writer's state after the failed merge (assertions on and off, 2026-10-27); release jars 7.4.0, 7.5.0, 7.6.0, 7.7.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2, with commit isolation on 7.7.0 and 8.0.0; a 584-case bounded-exhaustive run on main; independent fact checks of this doc, one of which re-ran the gradle commands, the fix and the full lucene/core suite. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5495-5536 (at a9779ef3c193) (mergeMiddle: wrapForMerge applied at :5497; with hard deletes (:5504) the surviving soft deletes are counted by countSoftDeletes; without hard deletes (:5531-5535) carryOverSoftDeletes = getSoftDelCount() - wrappedReader.numDeletedDocs() with the asserts 'carry-over soft-deletes must be positive' and assertSoftDeletesCount), :5427-5450 (countSoftDeletes), :5593 (merge.info.setSoftDelCount)
  • lucene/core/src/java/org/apache/lucene/index/SegmentCommitInfo.java:384-388 (at a9779ef3c193) (setSoftDelCount throws IllegalArgumentException 'invalid softDelCount=... (maxDoc=...)' for a negative count)
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:301-309 (at a9779ef3c193) (OneMerge#wrapForMerge: 'Wrap a reader prior to merging in order to add/remove fields or documents.'; only doc-ID reordering is illegal)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:551-555 (at a9779ef3c193) (setSoftDeletesField: 'Soft deletes allow to retain documents across merges if the merge policy modifies the live docs of a merge reader')
  • lucene/core/src/java/org/apache/lucene/index/OneMergeWrappingMergePolicy.java:27 (at a9779ef3c193) and SoftDeletesRetentionMergePolicy.java:44 (@lucene.experimental)
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:4941-4947 (at a9779ef3c193) (soft-delete check: 'actual soft deletes: N but expected: M')
  • SECURITY.md (line 28 threat model, line 42 configuration trusted, line 50 in-scope reports)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5427-5452 (countSoftDeletes), 5495-5536 (mergeMiddle soft-delete accounting), 5593 (setSoftDelCount)
  • lucene/core/src/java/org/apache/lucene/index/SegmentCommitInfo.java:384-388
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:301-309
  • lucene/core/src/java/org/apache/lucene/index/PendingDeletes.java:242-250; lucene/core/src/java/org/apache/lucene/search/MatchAllDocsQuery.java:57-58
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:499, 1114, 4673, 4741-4757, 4930-4960
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5109-5113 (catch -> tragicEvent), 6090-6105 (tragicEvent, maybeCloseOnTragicEvent -> rollbackInternal), 2286-2289 (forceMerge after a tragedy), 6824-6830 and 891-902 (getDocStats)
  • lucene/core/src/java/org/apache/lucene/index/SoftDeletesDirectoryReaderWrapper.java:142-147, 154-186; SegmentReader.java:93-108
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/SoftDeletesRetentionMergePolicy.java:60-72; IndexWriter.java:4059-4062; lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:127-130
  • lucene/core/src/java/org/apache/lucene/index/OneMergeWrappingMergePolicy.java:27
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:301-309; IndexWriterConfig.java:545-556; IndexWriter.java:4842-4845; CheckIndex.java:4945-4947
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/SoftDeletesRetentionMergePolicy.java:96-140; IndexWriter.java:5427-5452, 5499-5500
  • gradle/validation/ecj-lint/ecj.javadocs.prefs:146 (unusedPrivateMember=error)
  • lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:1092-1108 (getReaderForMerge: hard-live-docs snapshot)

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 with assertions on and off (re-run on 2026-10-27 to show the writer's state after each failed merge); the values on screen come 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.