All findings · Lucene finding · LFV-726 · addindexes-codecreader-stats-ignore-wrapformerge

addIndexes(CodecReader...) counts soft deletes before OneMerge#wrapForMerge removes documents, and later merges build on the wrong count

A custom merge policy's wrapForMerge that drops documents during addIndexes(CodecReader...) leaves the new segment with the pre-drop soft-delete count: 2 documents written, 1 nonexistent soft delete recorded. With assertions off, a later forceMerge(1) can then fit 3 documents into 2 doc IDs, and a search for one returns another.

Summary

Priority
Low, rank 323Needs a custom merge policy that overrides findMerges(CodecReader...) with a document-removing wrapper, which no in-tree or known downstream policy does.
Impact
data corruption
Since
9.9.0 (#12622, 2023)introduced 2023-11-23 · about 2 years
Affected
9.9.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
code reading of the addIndexes merge path, then a public-API JUnit

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

addIndexes(CodecReader...) lets your merge policy wrap each incoming reader, and the wrapper may "add/remove fields or documents". The writer must then describe the segment that was actually written: its document count and how many of its documents are soft-deleted.

Diagram of addIndexes(CodecReader...) with a merge wrapper that drops expired documents: the source segment holds a (expired, soft-deleted), c and a2; the writer counts 3 documents and 1 soft delete before the wrapper runs; the new segment holds c and a2 (maxDoc 2) but records 1 soft delete, so numDocs is 1 instead of 2 and getPendingNumDocs is 3 instead of 2; after adding z, forceMerge(1) commits a segment of maxDoc 2 in which id:z returns a2's document.
The 3-document example step by step: addIndexes counts a, c and a2 before the wrapper drops a, the new 2-document segment records 1 soft delete it does not hold, and a later forceMerge(1) puts c, a2 and z into 2 doc IDs. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Your writer has a soft-deletes field, and your merge policy's findMerges(CodecReader...) returns a OneMerge whose wrapForMerge hides expired documents. The source index holds a (expired, then soft-deleted), c and a2; a reader from DirectoryReader.open still sees all 3, and its leaves go to addIndexes.
  2. You expect the new segment to hold c and a2 with no soft deletes: maxDoc 2, numDocs 2 and getPendingNumDocs 2.
  3. Instead addIndexes counted before the wrapper ran: the segment holds 2 documents but records 1 soft delete, so numDocs is 1 and getPendingNumDocs is 3. CheckIndex notices only at level 2 and up.
  4. With assertions off, later operations trust that count. Add z and run forceMerge(1): with LogDocMergePolicy the merged segment has 2 doc IDs for 3 documents, id:z returns a2's document and CheckIndex is clean; with TieredMergePolicy the merge throws CorruptIndexException.
  5. In other shapes a live document vanishes at a later commit, or that commit fails with invalid softDelCount=2 (maxDoc=1).

Who is affected

Only applications whose own merge policy overrides findMerges(CodecReader...) with a OneMerge whose wrapForMerge removes documents; no in-tree policy does. The damaging half also needs a soft-deletes field and a wrapper that drops incoming soft-deleted documents. Then each such call commits a wrong count, with as few as 3 documents, in 9.9.0 to 10.6.0 (10.6.0 by reading) and main.

Mechanism, terms and numbers1,041 words

IndexWriter.addIndexes(CodecReader...) merges the incoming readers as the merge policy's findMerges(CodecReader...) says, and since 9.9.0 it calls OneMerge#wrapForMerge on each reader, which may "add/remove fields or documents". But it takes two counts from the readers before that call: the documents it reserves (getPendingNumDocs) and the soft-delete count it stores in the new segment. When the wrapper drops an incoming soft-deleted document, the committed segment claims a soft delete it does not hold. In the 3-document example the new segment holds c and a2, records 1 soft delete, reports numDocs 1 instead of 2, and the writer reserves 3 documents instead of 2. With assertions off (production) later operations build on the wrong count: a later ordinary forceMerge(1) after adding z commits a 2-document segment for 3 documents in which id:z returns a2's stored document and CheckIndex finds nothing (LogDocMergePolicy), or the merge throws CorruptIndexException (TieredMergePolicy); in other shapes a live document disappears at a later commit, once an NRT reader is opened or a delete is applied, or that commit throws invalid softDelCount. The regular merge path takes both counts after wrapping. Only a custom findMerges(CodecReader...) with a document-removing wrapForMerge triggers it; no in-tree policy removes documents on this path. Introduced by #12622 (9.9.0); affects 9.9.0 to 10.6.0 (10.6.0 by reading) and main.

Terms

  • addIndexes(CodecReader...): imports readers into the index by merging them into new segments. Which readers go into which merge is decided by MergePolicy#findMerges(CodecReader...), which "can be overridden in custom merge policies"; by default it is one merge for all readers.
  • OneMerge#wrapForMerge: a hook that wraps each reader "prior to merging in order to add/remove fields or documents". IndexWriter calls it on the regular merge path and, since 9.9.0, on the addIndexes path.
  • Soft deletes: with IndexWriterConfig.setSoftDeletesField("soft"), a document is hidden by giving it a doc value in that field (for example by softUpdateDocument). Each segment stores how many of its documents are soft-deleted, and the writer derives the live count from it: numDocs = maxDoc - deletes - soft deletes. A reader from DirectoryReader.open(dir) does not apply soft deletes, so soft-deleted documents arrive at addIndexes as live.
  • getPendingNumDocs: "the number of documents in the index including documents are being added (i.e., reserved)", used to enforce IndexWriter.MAX_DOCS.

The bug

  1. OneMerge(CodecReader...) sizes the merge from the incoming readers: totalMaxDoc is the sum of their numDocs() (MergePolicy.java:245-256).
  2. addIndexesReaderMerge counts soft deletes on those same unwrapped readers (IndexWriter.java:3500-3515): 1 in the example (a).
  3. Only then does it wrap them, merge.wrapForMerge(mr.codecReader) (IndexWriter.java:3542-3546). The wrapper hides a, the merge writes c and a2, and the new segment is created with the old count: new SegmentCommitInfo(segInfo, 0, numSoftDeleted, ...) (:3600-3601), that is maxDoc 2 and 1 soft delete.
  4. addIndexes then reserves merge.totalMaxDoc, 3, although the merged segment already knows it holds 2 (IndexWriter.java:3341-3356).
  5. Later code trusts the stored count. The live count becomes 2 - 1 = 1 (PendingDeletes.java:243-250). A segment whose stored deletes equal its maxDoc counts as fully deleted and is dropped at commit (PendingDeletes.java:219-220, IndexWriter.java:6429-6435). A merge sizes its output from the readers' live counts and gives each reader a doc base advanced by its live count (MergeState.java:170-173, :197-208). The segment has no document with the soft-deletes field, so no document is marked deleted, but its live count is 1: c and a2 map to doc 0 and 1, and z, whose doc base is 1, also maps to 1, in a 2-document segment. This explanation is read from the code and fits the LogDocMergePolicy observation; it was not stepped through in a debugger. TieredMergePolicy merges z's segment first (executed: an infoStream run logs "merging _1(11.0.0):c1 _0(11.0.0):c2"); by the same reading that gives doc IDs 0, 1 and 2 in a 2-document segment, which fits its "invalid docCount: 3 maxDoc: 2".

The regular merge path takes both counts after wrapping: commitMerge returns the documents a merge did not write to the reservation (IndexWriter.java:4990-5000), and mergeMiddle derives the soft-delete count from the wrapped reader (:5494-5540). A regular forceMerge with a dropping wrapper keeps getPendingNumDocs equal to maxDoc. (That mergeMiddle code has its own, separate flaw, LFV-730: for a segment without hard deletes it counts every live document a wrapper hides as a dropped soft delete.)

The numbers

Source: one segment with a (marked expired:true, then soft-deleted by softUpdateDocument(id:a -> a2)), c and a2. After addIndexes with a wrapper that hides expired documents, and commit():

expectedmain a9779ef3c193
new segment maxDoc22
stored soft deletes01
DocStats.numDocs21
getPendingNumDocs23
CheckIndex level 1 / level 2 and 3clean / cleanclean / "actual soft deletes: 0 but expected: 1"

Then addDocument(z), commit(), forceMerge(1), assertions off, default codec Lucene104: with LogDocMergePolicy the merged segment has maxDoc 2, id:c finds doc 0 (c), id:a2 finds doc 1 (a2), id:z finds doc 1 (a2), and CheckIndex is clean at levels 1, 2 and 3; with TieredMergePolicy the forceMerge throws CorruptIndexException: invalid docCount: 3 maxDoc: 2 and the earlier commit, with its wrong count, stays. With assertions on the forceMerge fails with AssertionError: pendingNumDocs 4 != 3 totalMaxDoc; underneath, the merge's tragic exception is softDeleteCount doesn't match 1 != 0 (PendingSoftDeletes.java:89-90), after which the writer closes itself and its close-time assertion fires.

Other shapes, assertions off: source a (soft-deleted), b, c with a wrapper that drops a and b leaves a 1-document segment that records 1 soft delete; after an NRT reader is opened or a delete is applied, the next commit drops it and the live c is gone (hits 0, maxDoc 0). With a and b both soft-deleted, numDocs is -1 and the next such commit throws invalid softDelCount=2 (maxDoc=1). Without soft deletes, 5 documents of which the wrapper drops 2 leave getPendingNumDocs at 10 after two calls while the index holds 6; a wrapper that revives a hard-deleted document makes the writer under-reserve (maxDoc 3, getPendingNumDocs 2; shown at this small size only).

Exposure and likelihood354 words
  • Who. Applications that import with addIndexes(CodecReader...) through their own MergePolicy whose findMerges(CodecReader...) returns a OneMerge with a document-removing wrapForMerge, for example to drop expired or tombstoned documents while importing. MergePolicy is an Expert API. OneMergeWrappingMergePolicy, the base of SoftDeletesRetentionMergePolicy, does not override findMerges(CodecReader...), so it does not wrap these merges. The misc BPReorderingMergePolicy does wrap them (it overrides findMerges(CodecReader...) and delegates wrapForMerge), but it only reorders documents; no in-tree policy removes documents on this path.
  • The soft-delete half also needs a soft-deletes field on the destination writer and a wrapper that drops incoming soft-deleted documents. Readers from DirectoryReader.open(dir) do not apply soft deletes, so such documents arrive as live, and a filter that drops them is a natural thing to write.
  • Effect. A wrong soft-delete count is committed whenever the wrapper changes how many soft-deleted documents survive and the merge writes a non-empty segment; no large size is needed (the repro has 3 documents). A wrapper that removes only documents that are not soft-deleted over-reserves without a wrong soft-delete count. DocStats.numDocs under-counts. With assertions off (production), later operations can drop a live document at a later commit (once an NRT reader is opened or a delete is applied), fail such a commit with invalid softDelCount, or make an ordinary forceMerge commit a segment in which one document's terms point at another document's stored fields; or the forceMerge throws CorruptIndexException.
  • Detection. CheckIndex checks the soft-delete count only at level 2 and up; its command-line default is level 1. After the silent forceMerge, no level reports anything.
  • The over-reservation alone is minor. It lasts for the writer session (a reopened writer recomputes it). Its demonstrated production effect is a stricter MAX_DOCS admission check, shown with a lowered test limit, not near 2^31; it also makes the experimental getPendingNumDocs() wrong and, with assertions on, trips the writer's close-time assertion. A wrapper that revives documents under-reserves instead (shown at small size only).
  • How rare. A judgement: no downstream project with such a policy was found (keyword search only), and nobody has reported it in the almost three years since 9.9.0.

Minimal repro

The test shows that IndexWriter.addIndexes(CodecReader...) stores the incoming reader's soft-delete count in the new segment even when the merge's OneMerge#wrapForMerge dropped the soft-deleted document (soft-deletes field, source segment [a soft-deleted, c]).

TestAddIndexesWrapForMergeSoftDeletes.java (JUnit test)108 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.DocIdSetIterator;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.Bits;
import org.apache.lucene.util.FixedBitSet;

public class TestAddIndexesWrapForMergeSoftDeletes extends LuceneTestCase {

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

  /** Imports readers through a OneMerge whose wrapForMerge drops soft-deleted documents. */
  private static final MergePolicy DROP_SOFT_DELETED_ON_ADD_INDEXES =
      new FilterMergePolicy(new LogDocMergePolicy()) {
        @Override
        public MergeSpecification findMerges(CodecReader... readers) {
          MergeSpecification spec = new MergeSpecification();
          spec.add(
              new OneMerge(readers) {
                @Override
                public CodecReader wrapForMerge(CodecReader reader) throws IOException {
                  FixedBitSet live = new FixedBitSet(reader.maxDoc());
                  live.set(0, reader.maxDoc());
                  NumericDocValues soft = reader.getNumericDocValues("soft");
                  for (int d = soft.nextDoc();
                      d != DocIdSetIterator.NO_MORE_DOCS;
                      d = soft.nextDoc()) {
                    live.clear(d);
                  }
                  return new FilterCodecReader(reader) {
                    @Override
                    public Bits getLiveDocs() {
                      return live;
                    }

                    @Override
                    public int numDocs() {
                      return live.cardinality();
                    }

                    @Override
                    public CacheHelper getCoreCacheHelper() {
                      return null;
                    }

                    @Override
                    public CacheHelper getReaderCacheHelper() {
                      return null;
                    }
                  };
                }
              });
          return spec;
        }
      };

  public void testSoftDeleteCountAfterAddIndexes() throws IOException {
    try (Directory src = new ByteBuffersDirectory();
        Directory dst = new ByteBuffersDirectory()) {
      IndexWriterConfig srcConfig =
          new IndexWriterConfig()
              .setSoftDeletesField("soft")
              .setMergePolicy(NoMergePolicy.INSTANCE);
      try (IndexWriter w = new IndexWriter(src, srcConfig)) {
        w.addDocument(doc("a"));
        w.softUpdateDocument(new Term("id", "a"), doc("c"), new NumericDocValuesField("soft", 1));
      }
      IndexWriterConfig dstConfig =
          new IndexWriterConfig()
              .setSoftDeletesField("soft")
              .setMergePolicy(DROP_SOFT_DELETED_ON_ADD_INDEXES);
      try (DirectoryReader r = DirectoryReader.open(src);
          IndexWriter w = new IndexWriter(dst, dstConfig)) {
        w.addIndexes((CodecReader) r.leaves().get(0).reader());
        w.commit();
        // expected 1 (c; the wrapper dropped soft-deleted a); actual 0: a's soft delete is kept
        assertEquals("DocStats.numDocs after addIndexes", 1, w.getDocStats().numDocs);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-726/, 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.TestAddIndexesWrapForMergeSoftDeletes
Expected vs actual
Expected getDocStats().numDocs = 1 (c is live and the new segment has no deleted document). Actual: 0, because the segment keeps softDelCount=1, counted before wrapForMerge.

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

More files, environment, full output47 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: DocStats.numDocs after addIndexes expected:<1> but was:<0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.index.TestAddIndexesWrapForMergeSoftDeletes.testSoftDeleteCountAfterAddIndexes(TestAddIndexesWrapForMergeSoftDeletes.java:104)
       Suppressed: java.lang.AssertionError: pendingNumDocs 2 != 1 totalMaxDoc
         org.apache.lucene.index.IndexWriter.rollbackInternal(IndexWriter.java:2596)
         org.apache.lucene.index.IndexWriter.shutdown(IndexWriter.java:1337)
         org.apache.lucene.index.IndexWriter.close(IndexWriter.java:1365)
         org.apache.lucene.index.TestAddIndexesWrapForMergeSoftDeletes.testSoftDeleteCountAfterAddIndexes(TestAddIndexesWrapForMergeSoftDeletes.java:99)

Why it looks unintended

  • wrapForMerge's javadoc allows it ("add/remove fields or documents"), wording added by the same change that made addIndexes call it.
  • The regular merge path takes its counts after wrapping, and IndexWriter asserts pendingNumDocs == totalMaxDoc on close.
  • The outcome is a corrupt or silently wrong index, and nothing documents a limitation.
The full argument229 words
  • Contract. OneMerge#wrapForMerge: "Wrap a reader prior to merging in order to add/remove fields or documents" (MergePolicy.java:301-309). #12622 replaced the older wording ("add/remove information to the merged segment") in the same commit that added the wrapForMerge call to addIndexesReaderMerge. findMerges(CodecReader...) "can be overridden in custom merge policies" (MergePolicy.java:619-623), and addIndexes(CodecReader...) says readers "are merged as specified by the MergePolicy#findMerges(CodecReader...) API" and points to overriding it (IndexWriter.java:3264-3267). OneMerge(CodecReader...) is public.
  • Invariants the code enforces. IndexWriter asserts pendingNumDocs == segmentInfos.totalMaxDoc() on close and rollback (IndexWriter.java:2592-2597). CheckIndex.checkSoftDeletes rejects a stored soft-delete count that differs from the segment (CheckIndex.java:4938-4947). TestAddIndexes.testAddIndicesWithSoftDeletes asserts that the added segments' soft-delete counts equal maxDoc minus the live documents (TestAddIndexes.java:1767-1806).
  • History. The count sites come from #633 (LUCENE-10216, 2022); while that PR was in development the same close-time assertion fired ("pendingNumDocs 7 != 5 totalMaxDoc"), and its author suspected their code was "reserving more docs than it should" and fixed it before merge. The counts were right until #12622 (9.9.0) made the merged readers differ from the counted ones; #12622's review never discusses them.
  • Consistency. The regular merge path takes both counts after wrapForMerge (IndexWriter.java:4990-5000, :5494-5540); only addIndexes takes them before. (The regular path has a separate flaw of its own for wrappers that hide live documents in segments without hard deletes, LFV-730.)
The devil's-advocate check194 words

The intended-behaviour verifier and reviews by a model from another family built the strongest case for "not a bug": the javadoc of findMerges(CodecReader...) names only concurrency control as the reason to override it; no in-tree policy and no test removes documents on this path; MergePolicy is Expert and getPendingNumDocs is @lucene.experimental; the usual way to filter is to wrap the readers before calling addIndexes, which is counted correctly; 9.4.0 to 9.8.0 ignored wrapForMerge on this path entirely, and #12622 has no CHANGES entry of its own (the BP algorithm it builds on is listed under GITHUB#12489 in 9.8.0; no entry mentions wrapForMerge on the addIndexes path); and over-reserving is conservative.

Why it fails: #12622 made removal the explicit contract at the moment the hook started running here; the API is public and documented as overridable; the writer's own assertions, CheckIndex and TestAddIndexes treat both counts as invariants; nothing documents a limitation; and the outcome is lost or merged documents, not a clean error. At most, the argument limits how common the trigger is. Verdict: a defect (every reviewer rated the intended-behaviour argument weak). Expect a maintainer to suggest filtering before addIndexes.

Suggested fix

Recommended A: reserve the merged segment's maxDoc and count soft deletes on the wrapped readers

Count what the merge wrote: reserve each merged segment's maxDoc instead of merge.totalMaxDoc, and count soft deletes on the wrapped readers (IndexWriter.java only, +9/-8). The 3 repro classes pass, 11 of 11 with assertions on and off, and so do 12 closest upstream test classes.

All options and trade-offs4 options ยท 634 words

One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF) with the three repro classes (stock: 11 tests, 9 failures, assertions on and off) and 12 closest upstream test classes. The other options are sketches, not implemented. Which one, or another design, is the maintainers' call.

A: reserve the merged segment's maxDoc and count soft deletes on the wrapped readers (validated by the checks) (recommended)

In addIndexes(CodecReader...), reserve merge.getMergeInfo().info.maxDoc() for each merge that produced a segment, instead of merge.totalMaxDoc. In addIndexesReaderMerge, move the soft-delete count from the loop before wrapping into the wrapping loop, on the wrapped reader and its live docs. One file, +9/-8.

// addIndexes(CodecReader...): reserve each merged segment's real size
for (MergePolicy.OneMerge merge : spec.merges) {
  if (merge.getMergeInfo() != null) {
    totalDocs += merge.getMergeInfo().info.maxDoc(); // was: totalDocs += merge.totalMaxDoc
    infos.add(merge.getMergeInfo());
  }
}
// addIndexesReaderMerge: count soft deletes on the wrapped readers
for (MergeReader mr : merge.getMergeReader()) {
  CodecReader reader = merge.wrapForMerge(mr.codecReader);
  readers.add(reader);
  if (softDeletesEnabled) {
    numSoftDeleted += PendingSoftDeletes.countSoftDeletes(
        FieldExistsQuery.getDocValuesDocIdSetIterator(config.getSoftDeletesField(), reader),
        reader.getLiveDocs());
  }
}

Trade-offs

  • For an identity wrapper the wrapped reader's live docs represent the same liveness state as the old hardLiveDocs, so nothing changes for existing users; the counting work is the same.
  • The best-effort pre-flight checks still use the unwrapped counts (IndexWriter.java:3296, :3518): a removing wrapper can still be rejected near MAX_DOCS for documents it would drop, and a reviving wrapper is caught only by the final reserveDocs. Both reviews by the other model family called this a defensible, conservative trade-off.
  • merge.totalMaxDoc keeps its current values (the inputs' maxDoc sum for segment merges, the unwrapped readers' numDocs() sum for addIndexes merges); only the addIndexes reservation stops using it.
  • Not done: the full core suite, check, a CHANGES entry. The sibling LFV-730 needs its own fix in mergeMiddle.

What the checks validated

  • Stock: the 3 repro classes run 11 tests with 9 failures, assertions on and off.
  • With A: 11 of 11 pass with assertions on and with assertions off.
  • With A, the 3 repro classes and the 12 closest upstream classes (core TestAddIndexes, TestIndexWriterMaxDocs, TestSoftDeletesRetentionMergePolicy, TestSoftDeletesDirectoryReaderWrapper, TestPendingSoftDeletes, TestOneMergeWrappingMergePolicy, TestFilterMergePolicy, TestMergePolicy, TestIndexWriterMergePolicy, TestIndexWriterForceMerge, TestCheckIndex; misc TestBPReorderingMergePolicy, the in-tree policy that wraps addIndexes merges to reorder them): 15 classes, 143 tests, 8 skipped (4 nightly, 1 monster, 3 @AwaitsFix), 0 failures, with assertions on and with assertions off.
  • With A, TestBPReorderingMergePolicy with the nightly group enabled: 3 of 3 pass, including testReorderOnAddIndexes.
  • :lucene:core:checkGoogleJavaFormat passes with A and the compact test.
  • Earlier checks with the same change: upstream TestAddIndexes 39 of 39; the independent reproducer's 6 tests, the devil's-advocate probes (the forceMerge and the reviving-wrapper cases) and both plain main() variants pass (3 distinct documents, CheckIndex clean at every level).

B: reject wrappers that change the live documents on this path

Throw in addIndexesReaderMerge when wrapForMerge returns a reader with different live documents (comparing which documents are live, not only how many, since a wrapper can swap live documents and keep the count), and document the limitation in the wrapForMerge and findMerges(CodecReader...) javadoc.

Trade-offs

Simple and fails loudly, but it takes away a capability the javadoc grants and the regular merge path honours. Not implemented.

C: recount soft deletes from the written segment

After merger.merge(), count the documents of the new segment that carry the soft-deletes field (a new segment has no hard deletes).

Trade-offs

Robust to any wrapper and to reorder, but it reads the new segment's soft-deletes doc values once more per addIndexes merge, and the reservation still needs option A's change. Not implemented.

D: documentation only

State that removing documents in wrapForMerge is unsupported for addIndexes merges.

Trade-offs

Leaves existing users exposed to silent corruption; not recommended.

Downstream note

Lucene's own OneMergeWrappingMergePolicy, the base of SoftDeletesRetentionMergePolicy, does not wrap addIndexes merges, so downstream wrappers built on it do not reach this path; only a merge policy that overrides findMerges(CodecReader...) itself does.

How it was found

Code reading, not a formal method. While checking how merge-policy wrappers compose with IndexWriter's merge paths, the finder read addIndexesReaderMerge and saw that both counts are taken before wrapForMerge runs. A public-API JUnit and a patched IndexWriter confirmed it; the unit's randomized composition matrix never reached this path.

The method in full175 words

Method: code reading (non-formal tier), in a hunt unit that tests how merge-policy wrappers compose with IndexWriter's merge paths (a randomized composition matrix with deliberately broken wrappers to check that its oracles can fail).

  • One of those deliberately broken wrappers drops documents; on the regular merge path it exposed the sibling bug LFV-730 (mergeMiddle). While checking whether addIndexes has the same problem, the finder read addIndexesReaderMerge and noticed that the reserved count and the soft-delete count are both taken before wrapForMerge.
  • The matrix's own addIndexes triggers use only in-tree wrappers, none of which removes documents, so the matrix did not reach this path.
  • The finder wrote a public-API JUnit (2 tests) and a patched IndexWriter: the tests fail on main, 10.5.2, 9.12.3 and 9.9.0 and pass with the patch.
  • 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 that ran their own tests, an adjudication, a follow-up (history, prior reports, security), the registration check and an audit.

Prior reports, history and checks

Prior reports

No prior report found (223 executed tool queries, 38 manual GitHub searches and 4 web searches, 2026-10-27): GitHub issues, PRs and commits, the JIRA archive, four mailing lists, downstream projects and the changed files of all 410 open PRs. Open draft #16847 edits the same lines but keeps the old counts.

Searches3 searches

Verdict new. The nearest items introduced the code, edit the same lines, or concern other accounting paths.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; downstream repositories2026-10-27five runs of the hunt's prior-report tool (3 x 45 + 45 + 43 = 223 executed queries: identifiers, symptoms, the filter-while-importing mechanism and the design history; four of the runs also planned 6 web queries each that were not run) and 28 manual GitHub searches on apache/lucene plus 10 on downstream repositories (wrapForMerge, addIndexes with soft deletes, "actual soft deletes")no report; related: #12622 (introduced it), #633 / #11253 (created the count sites), #10661 (the same assertion after an OutOfMemoryError), #16571 (multi-output merges; reworks soft-delete counting in mergeMiddle only, not addIndexes), elastic/elasticsearch#160862 (a downstream review noting that OneMergeWrappingMergePolicy does not wrap addIndexes merges)
Open apache/lucene PRs2026-10-27the changed files of all 410 open PRs; the 20 that touch the fix-site files were read#13342, #13822 and #16847 touch addIndexesReaderMerge; none changes the two counts (#16847, open draft, keeps them on the unwrapped readers)
JIRA archive; dev@, java-user@, issues@, commits@2026-10-27tool queries plus manual list searches for wrapForMerge, addIndexesReaderMerge, "actual soft deletes", pendingNumDocsno report

History

Introduced in 9.9.0 by #12622 (2023-11-23), which made addIndexes call wrapForMerge and left both counts on the unwrapped readers; bracketed to that commit. Affects 9.9.0 to 10.6.0 (10.6.0 by reading) and main; 9.4.0 to 9.8.0 ignore the wrapper on this path. Not fixed upstream.

Versions tested6 rows

Introduced by #12622 "Add a merge policy wrapper that performs recursive graph bisection on merge." (@jpountz, merged 2023-11-23; main f7cab164501, branch_9x backport 6b1538b3313). It added the wrapForMerge loop to addIndexesReaderMerge and reworded the wrapForMerge javadoc to "add/remove fields or documents", but left the reserved count and the soft-delete count on the unwrapped readers. The count sites are older: #633 (LUCENE-10216, @vigyasharma, 2022-07-06, 698f40ad51a) created OneMerge(CodecReader...) and the reservation, and the soft-delete counting dates from LUCENE-8341 / LUCENE-8381 (2018). They were right while the merged readers were the counted readers.

Releases between the executed ones are classified by reading the release tags (the wrapForMerge call and the count statements are present from 9.9.0 on, the call is absent in 9.4.x to 9.8.0). Upstream main a95c754ee003 (2026-10-26) and the heads of branch_10_6 (from which 10.6.0 was released on 2026-10-29) and the unreleased branch_10x have the same code (read).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe compact test 3 of 3 fail through gradle, assertions on and off; plain main(): silent forceMerge with LogDocMergePolicy, CorruptIndexException with TieredMergePolicy
9.9.0, 9.12.3, 10.0.0, 10.5.2 affectedthe history test's two count checks fail on all four (reserved 3 vs 2; stored soft deletes 1, actual 0); the finder's test fails as on main on 9.9.0, 9.12.3 and 10.5.2; the reproducer's tests fail 4 of 6 on 9.9.0, 9.12.3 and 10.5.2, including the lost live document
branch_9x 06d9a85bb7d vs 6b1538b3313 (#12622) bisectthe parent passes the history test's two count checks (wrapper not applied); the #12622 commit fails both
9.4.0, 9.8.0 not affectedaddIndexes ignores wrapForMerge, so nothing is removed and the counts match (a different, older behaviour)
9.3.0 API absentfindMerges(CodecReader...) and OneMerge(CodecReader...) do not exist; the test does not compile
fix A (scratch worktree at a9779ef3c193) not affected11 of 11 repro tests pass, assertions on and off; 12 closest upstream classes pass

Independent checks

An independent reproducer and a devil's-advocate verifier reproduced it with their own tests on main and released versions; reviews by a model from another family ran their own test suites and called it real; the adjudication confirmed it with high confidence, and the registration check and an audit re-ran the tests.

Checks and verdicts322 words

Each executing check wrote or re-ran its own JUnit on stock code; every control passed, and every check's tests pass with the fix.

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer real, wider impactOwn 6-test JUnit written from the claim: 4 fail on main, 10.5.2, 9.12.3 and 9.9.0 with assertions on and off, 2 controls pass. Added the lost live document, the invalid softDelCount commit, and that CheckIndex checks the count only at level 2 and up.
devil's advocate real; intended argument weakBuilt the strongest "intended" case and showed why it fails; found the forceMerge doc-ID collision and the under-reserving reviving wrapper; the fix removes both.
cross-model reviews (refute, intended) real, high (both)An independent reviewer from another model family (OpenAI GPT-5.6) ran its own suites: 3 of 5 and 3 of 4 tests fail on main, controls pass, and the second suite passes 4 of 4 with the fix. Both rated the intended-behaviour argument weak and noted the pre-flight checks trade-off.
adjudication confirmed, highRe-ran the independent reproduction's tests (4 of 6 fail with assertions on and off) and the intended-behaviour check's forceMerge probe (fails), and both with the fix (pass). Class: data corruption, the worst executed outcome.
Registration check and audit reproduces; one wording correctedRe-ran every test on a clean checkout and wrote the plain main() probe with the default codec. Corrected the earlier claim that every stored-field read throws: over 9 seeds the doc IDs collide every time, but 5 throw and 4 are silent; with LogDocMergePolicy and the default codec the merge is silent.
this doc's gradle runs fails on stock, passes with the fixThe compact test and the two earlier tests: 9 of 11 fail on stock with assertions on and off; 11 of 11 pass with fix A, with assertions on and off; the closest upstream classes pass with fix A.

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

Finding record

LFV-726, snapshot of 2026-11-06

Registry title
addIndexes(CodecReader...) takes the reserved document count and the new segment's soft-delete count from the readers before OneMerge#wrapForMerge, so a merge whose wrapper removes documents over-reserves documents and, with soft deletes, commits a wrong soft-delete count that can drop live documents or corrupt a later merge (since 9.9.0)
Area
index / IndexWriter addIndexes(CodecReader...) accounting (reserveDocs, addIndexesReaderMerge soft-delete count) x MergePolicy#findMerges(CodecReader...) / OneMerge#wrapForMerge
Priority
Low, rank 323: Needs a custom merge policy that overrides findMerges(CodecReader...) with a document-removing wrapper, which no in-tree or known downstream policy does.
Impact class
data corruption
Impact note
Only applications whose custom (Expert) MergePolicy overrides findMerges(CodecReader...) and returns a OneMerge whose wrapForMerge removes documents are hit, on every such addIndexes(CodecReader...) call and at any size: the over-reserved pending-document count alone is minor (writer session only, it matters near MAX_DOCS), but with a soft-deletes field and a wrapper that drops incoming soft-deleted documents the committed segment records soft deletes it does not hold, which with assertions off (production) can drop live documents on a later commit, make a later commit throw 'invalid softDelCount', or make a later ordinary forceMerge either commit a segment in which one document's terms point at another document's stored fields (silent; CheckIndex clean at every level) or fail with CorruptIndexException; no in-tree merge policy removes documents on this path.
Summary
You install a merge policy whose findMerges(CodecReader...) returns a OneMerge whose wrapForMerge removes documents (its javadoc allows this: 'add/remove fields or documents'), for example to drop soft-deleted or expired documents while importing, and call IndexWriter.addIndexes(CodecReader...); you expect the writer to account for the documents the merge actually wrote, as the regular merge path does. Instead addIndexes reserves the live-document count of the unwrapped readers, so getPendingNumDocs stays too high for the rest of the writer session, and with soft deletes it stores the unwrapped readers' soft-delete count in the new segment, so DocStats.numDocs under-counts and CheckIndex (level 2 and up) reports 'actual soft deletes: 0 but expected: 1'. With assertions off, later operations then build on that wrong count: a live document can vanish from the committed index, and a later ordinary forceMerge can merge two documents into one doc ID (a search for one returns the other) or fail with CorruptIndexException.
Introduced
f7cab1645017d863331b42900581b67d3591e2da (apache/lucene#12622, 'Add a merge policy wrapper that performs recursive graph bisection on merge.', author jpountz; branch_9x backport 6b1538b33136a4737ad0d22b3633e62f3d7f362b, same day) [exec: commit-level bracket, the parent 06d9a85bb7d is not affected and 6b1538b3313 is]. It made IndexWriter#addIndexesReaderMerge call merge.wrapForMerge on each incoming reader (IndexWriter.java:3542-3546) and reworded the OneMerge#wrapForMerge javadoc to 'add/remove fields or documents', but left the two counts on the unwrapped readers: merge.totalMaxDoc (MergePolicy.java:245-256), which addIndexes reserves (IndexWriter.java:3341-3356), and the soft-delete count (IndexWriter.java:3502-3512, persisted at 3600-3601). Those count sites come from 698f40ad51af (#633, LUCENE-10216, 2022-07-06; branch_9x 5dd8e9bdc5a) and were right until #12622, because before it the merged readers were exactly the counted readers [read: git log -S and git blame in the full-history clone]. (2023-11-23 (main f7cab164501 and branch_9x 6b1538b3313))
First affected version
9.9.0 (tag 2023-12-04) [exec]: first release containing 6b1538b3313; 9.8.0 does not contain it [git merge-base] and is not affected [exec]. On the 10.x line the main commit f7cab164501 first ships in 10.0.0 [exec].
Affected versions
9.9.0, 9.9.1, 9.9.2, 9.10.0, 9.11.0, 9.11.1, 9.12.0, 9.12.1, 9.12.2, 9.12.3, 10.0.0, 10.1.0, 10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 (by reading) and main (11.0.0-SNAPSHOT). Executed: 9.9.0, 9.12.3, 10.0.0, 10.5.2, main a9779ef3c193 and the commit build 6b1538b3313. Read only: the other releases (all four fix-site statements and the wrapForMerge call are present in every release tag from 9.9.0 to 10.5.2), and the heads branch_10_6 2763db11b0a (contained in 10.6.0, released 2026-10-29) and the unreleased branch_10x 1309534296e (same statements). Not affected: 9.4.0 to 9.8.0 (the API exists but addIndexes ignores OneMerge#wrapForMerge, so the counts match what is written [exec 9.4.0, 9.8.0; read 9.4.1 to 9.7.0: no wrapForMerge call]); before 9.4.0 the API does not exist (MergePolicy#findMerges(CodecReader...) and OneMerge(CodecReader...) were added by #633) [exec 9.3.0: compile error]. Not fixed upstream: main a95c754ee003 (2026-10-26) has identical IndexWriter.java, MergePolicy.java and PendingSoftDeletes.java [read: git diff].
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
non-formal: code reading
Upstream and prior reports
none found (searched 2026-10-27, read-only; the prior-report record, verdict new): three scripted prior-report searches of 51 queries each (identifiers and symptoms; the generic filter-while-importing mechanism; the design history of #633, #12622, #13128, #14994/#15025 and #15120) over GitHub issues, PRs and commits, downstream repositories, the dev, java-user, issues and commits lists and the JIRA archive, plus the finder's run (51 queries) and the intended-behaviour check's run (43 queries); 28 manual GitHub searches on apache/lucene and 10 on downstream repositories (wrapForMerge, addIndexes with soft deletes, 'actual soft deletes'); the file lists of all 410 open apache/lucene PRs (20 touch the fix-site files; their patches were read; #13342, #13822 and #16847 touch addIndexesReaderMerge, none changes the reserved count or where the soft-delete count is taken); list-archive statistics queries; 4 web queries. No issue, PR, commit, list thread, JIRA entry or downstream report describes this defect. Related only: #12622 (introducing change; its review never discusses the addIndexes counts), #633 / #11253 (LUCENE-10216: introduced the count sites; the same close-time assertion 'pendingNumDocs 7 != 5 totalMaxDoc' fired during the PR's development and was fixed before merge, before wrapForMerge was applied on this path), #10661 (LUCENE-9621, open: the same assertion after a tragic flush, different cause), #7360 (LUCENE-6299), #9091 (LUCENE-8043), #9504 (LUCENE-8458), #9615 (LUCENE-8569, the mergeMiddle shortcut behind the sibling candidate) and #9431 (LUCENE-8384) (other, fixed accounting bugs), #9324 (LUCENE-8277, open: validate CodecReaders in addIndexes), #5626 (LUCENE-4560, open: filtering during merge), #16847 (open draft: long doc ids; edits the same lines but keeps the pre-wrap counts), #16571 (open: soft-delete accounting in mergeMiddle, not addIndexes), and a downstream review in Elasticsearch #160862 (2026-10: the product's own statistics see pre-narrowing merge readers; OneMergeWrappingMergePolicy does not wrap addIndexes merges). Not fixed: upstream main a95c754ee003 (2026-10-26). Gaps: GitHub search is token-based and best effort; list search covered dev@, java-user@, issues@ and commits@ only; some web queries were throttled; private reports are not searchable.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): a compact public-API JUnit (3 tests) with the finder's and the independent reproducer's tests through gradle in a scratch worktree, assertions on and off, stock and with the fix, plus 12 closest upstream test classes with the fix (2026-10-27); repro runs by the finder, an independent reproducer, a devil's-advocate verifier, reviews by a model from another family that ran their own tests, the adjudication, the registration check and an audit; a plain main() program with the default codec; release jars 9.3.0, 9.4.0, 9.8.0, 9.9.0, 9.12.3, 10.0.0 and 10.5.2, and plain-javac builds of the introducing commit and its parent. OpenJDK 25.0.3, Linux aarch64. Upstream main a95c754ee003 (2026-10-26) has the same IndexWriter.java and MergePolicy.java.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3341-3356 (totalDocs += merge.totalMaxDoc; reserveDocs(totalDocs)), :3500-3513 (numSoftDeleted counted on reader.codecReader with hardLiveDocs), :3542-3546 (merge.wrapForMerge applied afterwards), :3600-3601 (new SegmentCommitInfo(segInfo, 0, numSoftDeleted, ...)); lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:245-256 (OneMerge(CodecReader...): totalMaxDoc = sum of r.numDocs()), :301-309 (wrapForMerge 'add/remove fields or documents'), :612-631 (findMerges(CodecReader...), may be overridden); re-read in the registration check; identical in Lucene main ea57360912ed and upstream main a95c754ee003 (a Lucene clone, git diff empty)
The finding in 30 seconds
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:245-256, 301-309, 615-631
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3264-3267, 3341-3356, 3500-3515, 3542-3546, 3600-3601, 4990-5000, 5494-5540, 6401-6409, 6429-6435
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/PendingDeletes.java:219-220, 243-250; MergeState.java:170-173, 197-208
Who is affected
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:52 (Expert); CheckIndex.java:1114, 1151-1156, 4748, 4754
  • a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:128-129, 200-203; lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2592-2597, 3579-3580
Why it looks unintended
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:301-309, 619-623; IndexWriter.java:2592-2597, 3264-3267; CheckIndex.java:4938-4947; lucene/core/src/test/org/apache/lucene/index/TestAddIndexes.java:1767-1806
  • a Lucene main checkout lucene/CHANGES.txt:3330 (9.8.0 header), 3360-3363 (GITHUB#12489)
Suggested fix
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3296, 3518; MergePolicy.java:249

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 (default codec, assertions on and off, and with the fix); 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.