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.
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.- Your writer has a soft-deletes field, and your merge policy's
findMerges(CodecReader...)returns aOneMergewhosewrapForMergehides expired documents. The source index holdsa(expired, then soft-deleted),canda2; a reader fromDirectoryReader.openstill sees all 3, and its leaves go toaddIndexes. - You expect the new segment to hold
canda2with no soft deletes:maxDoc2,numDocs2 andgetPendingNumDocs2. - Instead
addIndexescounted before the wrapper ran: the segment holds 2 documents but records 1 soft delete, sonumDocsis 1 andgetPendingNumDocsis 3.CheckIndexnotices only at level 2 and up. - With assertions off, later operations trust that count. Add
zand runforceMerge(1): withLogDocMergePolicythe merged segment has 2 doc IDs for 3 documents,id:zreturnsa2's document andCheckIndexis clean; withTieredMergePolicythe merge throwsCorruptIndexException. - In other shapes a live document vanishes at a later commit, or that commit fails with
invalid softDelCount=2 (maxDoc=1).
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.
addIndexes(CodecReader...): imports readers into the index by merging them into new segments. Which readers go into which merge is decided byMergePolicy#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".IndexWritercalls it on the regular merge path and, since 9.9.0, on theaddIndexespath.- Soft deletes: with
IndexWriterConfig.setSoftDeletesField("soft"), a document is hidden by giving it a doc value in that field (for example bysoftUpdateDocument). 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 fromDirectoryReader.open(dir)does not apply soft deletes, so soft-deleted documents arrive ataddIndexesas live. getPendingNumDocs: "the number of documents in the index including documents are being added (i.e., reserved)", used to enforceIndexWriter.MAX_DOCS.
OneMerge(CodecReader...)sizes the merge from the incoming readers:totalMaxDocis the sum of theirnumDocs()(MergePolicy.java:245-256).addIndexesReaderMergecounts soft deletes on those same unwrapped readers (IndexWriter.java:3500-3515): 1 in the example (a).- Only then does it wrap them,
merge.wrapForMerge(mr.codecReader)(IndexWriter.java:3542-3546). The wrapper hidesa, the merge writescanda2, and the new segment is created with the old count:new SegmentCommitInfo(segInfo, 0, numSoftDeleted, ...)(:3600-3601), that ismaxDoc2 and 1 soft delete. addIndexesthen reservesmerge.totalMaxDoc, 3, although the merged segment already knows it holds 2 (IndexWriter.java:3341-3356).- Later code trusts the stored count. The live count becomes
2 - 1 = 1(PendingDeletes.java:243-250). A segment whose stored deletes equal itsmaxDoccounts 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:canda2map to doc 0 and 1, andz, whose doc base is 1, also maps to 1, in a 2-document segment. This explanation is read from the code and fits theLogDocMergePolicyobservation; it was not stepped through in a debugger.TieredMergePolicymergesz's segment first (executed: aninfoStreamrun 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.)
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():
| expected | main a9779ef3c193 | |
|---|---|---|
new segment maxDoc | 2 | 2 |
| stored soft deletes | 0 | 1 |
DocStats.numDocs | 2 | 1 |
getPendingNumDocs | 2 | 3 |
CheckIndex level 1 / level 2 and 3 | clean / clean | clean / "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 ownMergePolicywhosefindMerges(CodecReader...)returns aOneMergewith a document-removingwrapForMerge, for example to drop expired or tombstoned documents while importing.MergePolicyis an Expert API.OneMergeWrappingMergePolicy, the base ofSoftDeletesRetentionMergePolicy, does not overridefindMerges(CodecReader...), so it does not wrap these merges. The miscBPReorderingMergePolicydoes wrap them (it overridesfindMerges(CodecReader...)and delegateswrapForMerge), 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.numDocsunder-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 withinvalid softDelCount, or make an ordinaryforceMergecommit a segment in which one document's terms point at another document's stored fields; or theforceMergethrowsCorruptIndexException. - Detection.
CheckIndexchecks the soft-delete count only at level 2 and up; its command-line default is level 1. After the silentforceMerge, 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_DOCSadmission check, shown with a lowered test limit, not near 2^31; it also makes the experimentalgetPendingNumDocs()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.
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]).
/*
* 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);
}
}
}
}
# 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.
More files, environment, full output47 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: 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)wrapForMerge's javadoc allows it ("add/remove fields or documents"), wording added by the same change that madeaddIndexescall it.- The regular merge path takes its counts after wrapping, and
IndexWriterassertspendingNumDocs == totalMaxDocon 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 thewrapForMergecall toaddIndexesReaderMerge.findMerges(CodecReader...)"can be overridden in custom merge policies" (MergePolicy.java:619-623), andaddIndexes(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.
IndexWriterassertspendingNumDocs == segmentInfos.totalMaxDoc()on close and rollback (IndexWriter.java:2592-2597).CheckIndex.checkSoftDeletesrejects a stored soft-delete count that differs from the segment (CheckIndex.java:4938-4947).TestAddIndexes.testAddIndicesWithSoftDeletesasserts that the added segments' soft-delete counts equalmaxDocminus 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); onlyaddIndexestakes 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.
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 nearMAX_DOCSfor documents it would drop, and a reviving wrapper is caught only by the finalreserveDocs. Both reviews by the other model family called this a defensible, conservative trade-off. merge.totalMaxDockeeps its current values (the inputs'maxDocsum for segment merges, the unwrapped readers'numDocs()sum foraddIndexesmerges); only theaddIndexesreservation stops using it.- Not done: the full core suite,
check, a CHANGES entry. The sibling LFV-730 needs its own fix inmergeMiddle.
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; miscTestBPReorderingMergePolicy, the in-tree policy that wrapsaddIndexesmerges 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,
TestBPReorderingMergePolicywith the nightly group enabled: 3 of 3 pass, includingtestReorderOnAddIndexes. :lucene:core:checkGoogleJavaFormatpasses with A and the compact test.- Earlier checks with the same change: upstream
TestAddIndexes39 of 39; the independent reproducer's 6 tests, the devil's-advocate probes (theforceMergeand the reviving-wrapper cases) and both plainmain()variants pass (3 distinct documents,CheckIndexclean at every level).
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.
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.
State that removing documents in wrapForMerge is unsupported for addIndexes merges.
Trade-offs
Leaves existing users exposed to silent corruption; not recommended.
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.
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 whetheraddIndexeshas the same problem, the finder readaddIndexesReaderMergeand noticed that the reserved count and the soft-delete count are both taken beforewrapForMerge. - The matrix's own
addIndexestriggers 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; downstream repositories | 2026-10-27 | five 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 PRs | 2026-10-27 | the 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-27 | tool queries plus manual list searches for wrapForMerge, addIndexesReaderMerge, "actual soft deletes", pendingNumDocs | no report |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the 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 | affected | the 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) | bisect | the parent passes the history test's two count checks (wrapper not applied); the #12622 commit fails both |
| 9.4.0, 9.8.0 | not affected | addIndexes ignores wrapForMerge, so nothing is removed and the counts match (a different, older behaviour) |
| 9.3.0 | API absent | findMerges(CodecReader...) and OneMerge(CodecReader...) do not exist; the test does not compile |
fix A (scratch worktree at a9779ef3c193) | not affected | 11 of 11 repro tests pass, assertions on and off; 12 closest upstream classes pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| independent reproducer | real, wider impact | Own 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 weak | Built 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, high | Re-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 corrected | Re-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 fix | The 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 469 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,413 words
- 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 plainmain()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 maina95c754ee003(2026-10-26) has the sameIndexWriter.javaandMergePolicy.java.
- 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-631a 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-6435a 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, 4754a 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-1806a 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
- TestAddIndexesWrapForMergeSoftDeletes.java (JUnit test):
repro/LFV-726/src/org/apache/lucene/index/TestAddIndexesWrapForMergeSoftDeletes.java(108 lines)
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.
assets/LFV-726/bug.png(469 KB)
