During a merge, the merge policy may wrap each segment's reader and hide documents. With soft deletes, IndexWriter must also record how many soft-deleted documents the new segment keeps. For a segment without hard deletes it subtracts instead of counting, which is right only if the wrapper never hides a live document.
- You set
setSoftDeletesField("soft")and a merge policy whoseOneMerge#wrapForMergedrops expired documents; its javadoc says a wrapper may "add/remove fields or documents". Segment 1 holds a and b (expired), segment 2 holds c. Nothing is soft-deleted. - You call
forceMerge(1)and expect one segment with a and c, which is what the same code gives without a soft-deletes field. - For a segment without hard deletes, IndexWriter works out the soft deletes to keep as
softDelCountminus the wrapped reader'snumDeletedDocs: 0 - 1 = -1, because the dropped live document b counts as a removed soft delete. The merge throwsinvalid softDelCount=-1 (maxDoc=2), and as a tragic event it closes theIndexWriter: uncommitted changes are lost and every later call throwsAlreadyClosedException. - Put
SoftDeletesRetentionMergePolicyon top and keep one soft-deleted document (a, replaced by a2): the sum is 1 - 1 = 0 instead of 1, and with assertions off it is committed. A search finds 2 documents, butnumDocsandIndexSearcher#countsay 3. - CheckIndex at level 2 reports
actual soft deletes: 1 but expected: 0, and once a is no longer retained the next merge throwsinvalid softDelCount=-1 (maxDoc=3)and closes the writer. A segment with one unrelated hard delete is counted properly and merges fine.
Applications that set a soft-deletes field and install their own merge wrapper that hides live documents, for example to expire documents at merge time. No in-tree policy does this, so default configurations are safe. For those applications it is deterministic on every merged segment without hard deletes: the merge fails and closes the IndexWriter, or a too-low count is stored.
Mechanism, terms and numbers1,018 words
OneMerge#wrapForMerge is documented as the place to "add/remove fields or documents" before a merge, for example to drop expired documents. When the index also has a soft-deletes field, the merge must record how many soft-deleted documents the new segment still holds. For a wrapped segment with hard deletes, IndexWriter.mergeMiddle counts them; for one without hard deletes it subtracts, getSoftDelCount() - wrappedReader.numDeletedDocs(), which is right only if the wrapper never hides a live document. With nothing soft-deleted, a wrapper that drops one expired document gives -1 and forceMerge(1) throws invalid softDelCount=-1 (maxDoc=2); the failure is a tragic event that closes the IndexWriter and rolls back its uncommitted changes, and a reopened writer fails the same way. Under SoftDeletesRetentionMergePolicy with one retained soft-deleted document the merge succeeds (assertions off) but records 0 soft deletes instead of 1: numDocs, IndexSearcher#count and getDocStats().numDocs say 3 while 2 documents are live, CheckIndex at level 2 reports actual soft deletes: 1 but expected: 0, and once the document is no longer retained the next merge of that segment throws invalid softDelCount=-1 (maxDoc=3) and closes the writer. Searches through the writer's readers stay right. The same merge works without a soft-deletes field, or when the segment happens to hold one unrelated hard delete. The shortcut came with LUCENE-8569 (2018); releases 7.4.0-7.6.0 counted correctly, every release from 7.7.0 to 10.6.0 (10.6.0 by reading) and main is affected. It needs the application's own document-dropping merge wrapper, so default configurations are not affected.
- Soft deletes: with
IndexWriterConfig.setSoftDeletesField("soft"),softUpdateDocument(orupdateDocValueson that field) hides a document by giving it a doc value in the soft-deletes field instead of marking it deleted. Each segment stores how many such documents it holds (softDelCount); the readers the writer hands out computenumDocsasmaxDoc - delCount - softDelCount. - Merge wrapper:
MergePolicy.OneMerge#wrapForMergemay wrap each segment's reader before the merge "in order to add/remove fields or documents" (MergePolicy.java:301-309);OneMergeWrappingMergePolicyis the stock way to install one. A wrapper that returns fewer live documents drops them from the merged segment. - SoftDeletesRetentionMergePolicy: a wrapper that does the opposite, it revives soft-deleted documents that match a retention query so they survive the merge.
IndexWriter.mergeMiddlewraps each reader (IndexWriter.java:5497). With soft deletes and a wrapped reader (:5499-5500) it must compute the soft deletes that survive.- With hard deletes it counts. If the segment has hard deletes (
:5504),countSoftDeletes(:5427-5452) walks the soft-deletes doc values and counts the documents still live in the wrapped reader. Right for any wrapper. - Without hard deletes it subtracts (
:5530-5535):getSoftDelCount() - wrappedReader.numDeletedDocs(), with the assertions "carry-over soft-deletes must be positive" and a recount check. If the segment hasssoft-deleted documents and the wrapper revivesrand hidesklive ones,numDeletedDocsiss - r + k, so the result isr - kinstead ofr. Right only whenk = 0. - The total is stored with
merge.info.setSoftDelCount(...)(:5593). A negative total throwsIllegalArgumentException("invalid softDelCount=...")(SegmentCommitInfo.java:384-388); a too-low total is committed. Only the total is checked at run time: with assertions off, one segment's negative carry-over can hide behind another segment's positive count. - A failed merge closes the writer. The exception escapes
IndexWriter.merge, whosecatch (Throwable t)callstragicEvent(t, "merge")(IndexWriter.java:5109-5113);maybeCloseOnTragicEventthen runsrollbackInternal(:6098-6105). The writer drops everything since its last commit and closes; every later call throwsAlreadyClosedException. Under the defaultConcurrentMergeSchedulerthe merge thread does the same andforceMerge(1)throwsIllegalStateException: this writer hit an unrecoverable error; cannot complete forceMerge(:2286-2289). - What reads it. In the writer's readers (
DirectoryReader.open(IndexWriter)) andIndexWriter#getDocStats(),numDocsis derived from the stored count (PendingDeletes.java:242-250), andIndexSearcher#count(MatchAllDocsQuery)returnsnumDocsper leaf (search/MatchAllDocsQuery.java:57-58), while live docs are rebuilt from the doc values, so their searches stay right. ASoftDeletesDirectoryReaderWrapperover a directory reader recounts from the doc values (SoftDeletesDirectoryReaderWrapper.java:142-147) and, with assertions off, reports the right count. CheckIndex compares the stored and the counted value (CheckIndex.java:4941-4947) only at level 2 or higher (:1114); neither the command-line default (level 1) nor an APICheckIndexwithoutsetLevel(level 0) runs that check. - The next merge. Once the retention query stops matching, the wrapper hides the soft-deleted document again and the shortcut computes 0 - 1 = -1 from the too-low stored count: that merge throws and closes the writer.
| scenario | expected | got |
|---|---|---|
soft-deletes field, nothing soft-deleted, wrapper drops b; forceMerge(1) | a, c | IllegalArgumentException: invalid softDelCount=-1 (maxDoc=2); the IndexWriter closes itself, a retry throws AlreadyClosedException |
a new IndexWriter on that index, add u (not committed), forceMerge(1) | a, c, u | invalid softDelCount=-1 (maxDoc=3); the writer closes again and u is lost |
| control: no soft-deletes field | a, c | a, c |
| control: segment 1 also holds one unrelated hard delete | a, c | a, c |
| retention keeps a (replaced by a2), wrapper drops b | softDelCount 1, count 2 | softDelCount 0; search 2 hits, count, numDocs, getDocStats().numDocs 3 |
| CheckIndex on that index, level 1 / level 2 | clean / clean | clean / actual soft deletes: 1 but expected: 0 |
retention ends, add d, forceMerge(1) | c, a2, d | invalid softDelCount=-1 (maxDoc=3); the writer closes |
With assertions on, every bug row fails at IndexWriter's own assertion instead (carry-over soft-deletes must be positive, or soft-deletes count mismatch expected: 0 but actual: 1), which closes the writer too. A bounded-exhaustive run on the real code covers 584 combinations (segments of 1-3 documents, each live, soft-deleted or hard-deleted, and a wrapper that keeps, removes or revives each one; reviving a hard-deleted document is excluded, so 584 of 819). It fails in exactly the 103 cases where the segment has no hard deletes and the wrapper removes a live document, and in none of the other 481. With assertions off 74 of the 103 throw and 29 commit a wrong count; with assertions on all 103 throw.
Exposure and likelihood279 words
- Who. Applications that set a soft-deletes field and install their own merge policy whose
OneMerge#wrapForMergehides live documents, for example dropping expired documents at merge time throughOneMergeWrappingMergePolicy(@lucene.experimental), alone or underSoftDeletesRetentionMergePolicy. The production overrides ofwrapForMergein Lucene revive (SoftDeletesRetentionMergePolicy) or delegate (IndexWriter, misc'sBPReorderingMergePolicy), so default configurations are not affected. - Trigger. Deterministic whenever a merge covers a segment without hard deletes and the wrapper hides at least one live document in it; the introducing commit calls segments without hard deletes "the common case". No scale, concurrency or special data.
- Effect. Loud form: the merge throws and, as a tragic event, closes the
IndexWriter: uncommitted changes are rolled back, every later call throwsAlreadyClosedException, and a reopened writer closes again on its next merge of the same segments (also under the defaultConcurrentMergeScheduler). Silent form (assertions off, as in production, with a retention policy): the committed segment records too few soft deletes; the writer's document counts over-count by the number of hidden documents, CheckIndex at level 2 or higher reports corruption, and the next merge of that segment throws and closes the writer once the retained documents are no longer retained. Searches through the writer's readers still return only live documents; no committed document is lost or revived. With assertions on, every form fails at IndexWriter's own assertion, which closes the writer too. - How rare. A judgement: the combination is uncommon. Apache Solr main has a
wrapForMergethat drops documents but does not use soft deletes; GitHub searches of Elasticsearch, OpenSearch and Solr for the symptom strings return nothing; other downstream merge policies were not surveyed.
The test shows that with a soft-deletes field configured, a merge whose OneMerge#wrapForMerge hides one live document (segments {a, b} and {c}, no hard deletes, b hidden) fails, because IndexWriter derives the carried-over soft deletes as getSoftDelCount() - numDeletedDocs() = -1.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.index;
import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.Bits;
import org.apache.lucene.util.FixedBitSet;
public class TestSoftDeletesMergeWrapperDropsDoc extends LuceneTestCase {
public void testMergeWrapperDropsLiveDoc() throws IOException {
IndexWriterConfig iwc =
new IndexWriterConfig()
.setSoftDeletesField("soft")
.setCodec(TestUtil.getDefaultCodec())
.setMergeScheduler(new SerialMergeScheduler())
.setMergePolicy(
new OneMergeWrappingMergePolicy(
new LogDocMergePolicy(),
merge ->
new MergePolicy.OneMerge(merge.segments) {
@Override
public CodecReader wrapForMerge(CodecReader reader) throws IOException {
return dropDoc(reader, "b");
}
}));
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
w.addDocument(doc("a"));
w.addDocument(doc("b"));
w.commit();
w.addDocument(doc("c"));
w.commit();
// expected: the merge drops b and keeps a, c; actual: it throws (merged softDelCount is -1)
w.forceMerge(1);
assertEquals(2, w.getDocStats().numDocs);
}
}
private static Document doc(String id) {
Document doc = new Document();
doc.add(new StringField("id", id, Field.Store.NO));
return doc;
}
private static CodecReader dropDoc(CodecReader reader, String id) throws IOException {
PostingsEnum postings = reader.postings(new Term("id", id));
int drop = postings == null ? -1 : postings.nextDoc();
Bits liveDocs = reader.getLiveDocs();
FixedBitSet live = new FixedBitSet(reader.maxDoc());
for (int doc = 0; doc < reader.maxDoc(); doc++) {
if (doc != drop && (liveDocs == null || liveDocs.get(doc))) {
live.set(doc);
}
}
int numDocs = live.cardinality();
return new FilterCodecReader(reader) {
@Override
public Bits getLiveDocs() {
return live;
}
@Override
public int numDocs() {
return numDocs;
}
@Override
public CacheHelper getCoreCacheHelper() {
return in.getCoreCacheHelper();
}
@Override
public CacheHelper getReaderCacheHelper() {
return null;
}
};
}
}
# in a copy of this finding's repro folder (repro/LFV-730/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.index.TestSoftDeletesMergeWrapperDropsDoc
- Expected vs actual
- Expected
forceMerge(1)to drop b and keep a and c; actual:AssertionError: carry-over soft-deletes must be positivewith-ea,IllegalArgumentException: invalid softDelCount=-1 (maxDoc=2)with-da.
More files, environment, full output40 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: carry-over soft-deletes must be positive
org.apache.lucene.index.IndexWriter.mergeMiddle(IndexWriter.java:5533)
org.apache.lucene.index.IndexWriter.merge(IndexWriter.java:5081)
org.apache.lucene.index.IndexWriter$IndexWriterMergeSource.merge(IndexWriter.java:6884)
org.apache.lucene.index.SerialMergeScheduler.merge(SerialMergeScheduler.java:38)
org.apache.lucene.index.IndexWriter.executeMerge(IndexWriter.java:2451)
org.apache.lucene.index.IndexWriter.maybeMerge(IndexWriter.java:2446)
org.apache.lucene.index.IndexWriter.forceMerge(IndexWriter.java:2281)
org.apache.lucene.index.IndexWriter.forceMerge(IndexWriter.java:2229)
org.apache.lucene.index.TestSoftDeletesMergeWrapperDropsDoc.testMergeWrapperDropsLiveDoc(TestSoftDeletesMergeWrapperDropsDoc.java:56)wrapForMergemay "add/remove fields or documents"; its only stated restriction is no doc-ID reordering.- Same wrapper, same documents: it works without a soft-deletes field, or with one unrelated hard delete in the segment.
- Releases 7.4.0-7.6.0 counted correctly, and the review of the 2018 shortcut called a negative result "a bug".
The full argument213 words
- Contracts.
OneMerge#wrapForMerge: "Wrap a reader prior to merging in order to add/remove fields or documents"; only reordering doc IDs is illegal (MergePolicy.java:301-309, wording since 9.9.0 via #12622; before: "add/remove information to the merged segment").setSoftDeletesField: "Soft deletes allow to retain documents across merges if the merge policy modifies the live docs of a merge reader" (IndexWriterConfig.java:551-552). IndexWriter's own comment: "this is also required if any MergePolicy modifies the liveDocs" (IndexWriter.java:4844-4845). CheckIndex treats a mismatching count as corruption. - The inconsistency. No soft-deletes field: works. Soft-deletes field plus one unrelated hard delete in the segment: works (the counting path). Soft-deletes field, no hard delete: fails. A deliberate rule would not depend on an unrelated hard delete.
- History. 7.4.0-7.6.0 counted the surviving soft-deleted documents of every wrapped segment. LUCENE-8569 (#9615, apache/lucene-solr#502, 2018) replaced that with the subtraction as an optimization that gives "accurate counts"; neither the issue nor the PR mentions wrappers that hide live documents. In that review the negative-carry-over check was an
IllegalStateException; a reviewer asked "AssertionErrorinstead? This means we have a bug right?", and it became the assert: a negative result was treated as an IndexWriter bug, not as misuse. - Tests. No upstream test removes documents in
wrapForMergein a soft-deletes index.
The devil's-advocate check236 words
The intended-behaviour verifier and a cross-model review built the strongest case for "not a bug": the merge-policy hooks are expert API and OneMergeWrappingMergePolicy is @lucene.experimental; every in-tree wrapper only revives; the shortcut's assertions show its author expected revive-only wrappers; and the setSoftDeletesField sentence can be read as "with soft deletes, a merge policy changes live docs only to retain documents".
Why it fails: no document states a revive-only rule and wrapForMerge explicitly allows removing documents; the setSoftDeletesField sentence names retention "for instance" and restricts nothing; the review of the introducing change called a negative carry-over a bug; the outcome flips on an unrelated hard delete; 7.4.0-7.6.0 handled the same wrapper correctly; @lucene.experimental permits API changes, not a merge that stores a wrong count or closes the writer; and IndexWriter treats the outcome as a tragic event, not as a cleanly rejected policy. Indexing every document with softUpdateDocument, as setSoftDeletesField asks, fails the same way, so it is not misuse. Both rated the case weak. Verdict: a defect.
Points a maintainer may still raise (a cross-model fact check found the rebuttal fair but incomplete here): the explicit "add/remove fields or documents" wording came in 9.9.0, five years after the shortcut; the usual way to remove documents is a delete; nobody reported it in eight years; and LUCENE-8569 had a real performance motive. None of them makes a closed writer or a wrong stored count acceptable.
Recommended Count the surviving soft deletes in both branches
Count the soft-deleted documents that stay live in the wrapped reader for every wrapped segment, as segments with hard deletes (and releases before 7.7.0) already do: one change in IndexWriter.mergeMiddle, +9/-14. The repros pass with assertions on and off; the full lucene/core suite (8,899 tests) passes with assertions on.
} else {
// The wrapper may revive soft-deleted documents and may also remove documents
// (OneMerge#wrapForMerge), so the carried-over soft deletes cannot be derived from
// the deletion counts alone: count the soft-deleted documents that stay live.
countSoftDeletes(
wrappedReader, wrappedReader.getLiveDocs(), null, softDeleteCount, Counter.newCounter(false));
}All options and trade-offs4 options ยท 623 words
One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF). The other options are sketches, not run. Which one, or another design, is the maintainers' call.
Replace the subtraction in the no-hard-deletes branch with countSoftDeletes, the counting the hard-deletes branch uses and that 7.4.0-7.6.0 used for every wrapped segment, and delete assertSoftDeletesCount, which becomes unused (the ECJ lint settings make an unused private member an error). Patch: fix-count-soft-deletes.diff, +9/-14 in IndexWriter.java. Equivalent by reading: drop the hardLiveDocs != null split and always call countSoftDeletes with hardLiveDocs, as 7.6.0 did.
} else {
// The wrapper may revive soft-deleted documents and may also remove documents
// (OneMerge#wrapForMerge), so the carried-over soft deletes cannot be derived from
// the deletion counts alone: count the soft-deleted documents that stay live.
countSoftDeletes(
wrappedReader, wrappedReader.getLiveDocs(), null, softDeleteCount, Counter.newCounter(false));
}Trade-offs
- It gives back the LUCENE-8569 optimization for wrapped segments without hard deletes: one pass over the soft-deletes doc values of each such segment (only documents that carry a soft-delete value, one
Bits.geteach). Segments that are not wrapped still skip all accounting. - For
SoftDeletesRetentionMergePolicy, the wrapper of such a segment already runs a query over its soft-deleted documents and copies its live docs (SoftDeletesRetentionMergePolicy.java:97-130), so the extra pass is of the same order (by reading). No benchmark was run.
What the checks validated
- Stock: the compact test and the finder's test together run 10 tests: 6 failed and 1 skipped with assertions on, 7 failed with assertions off.
- With the fix: 10 of 10 pass with assertions on and with assertions off.
- With the fix: the full
lucene/coretest suite passes (750 classes, 8,899 tests, 322 skipped, 0 failures, assertions on, including both repro classes and the soft-delete test classes);:lucene:core:ecjLintand:lucene:core:checkGoogleJavaFormatpass. - Earlier, with the same change shadowed: the 584-case exhaustive run has 0 failures (assertions on and off);
TestSoftDeletesRetentionMergePolicy(14 tests) andTestSoftDeletesDirectoryReaderWrapper(5 tests) pass over 3 iterations each; the independent reproducer's 6 tests pass.
No cheap runtime check can tell "revives r, hides k" from "revives r - k, hides nothing": both give the same numDeletedDocs. So the shortcut would need the wrapper to declare that it only revives, for example through a new OneMerge method that SoftDeletesRetentionMergePolicy overrides.
Trade-offs
- New API surface; a chain such as retention over a user wrapper needs every link to declare it; the saving is small by the trade-off above.
- A generic runtime test ("the wrapped live docs are a superset of the original ones") is no cheaper: it costs a pass over
maxDocand does not cover a wrapper that changes the soft-deletes field itself, whichwrapForMergealso permits.
Turn the assertion into an explicit exception and document that wrappers in soft-deletes indexes must not hide live documents.
Trade-offs
- Contradicts the
wrapForMergejavadoc and rejects merges that work without a soft-deletes field; it still fails inside the merge, so the writer still closes; a too-low but non-negative count can only be detected by counting, which is the first option's cost.
Drop expired documents with IndexWriter#deleteDocuments(Query) instead of hiding them in wrapForMerge; a segment with hard deletes takes the counting path. The delete must already be applied when the merge takes its readers: the branch is chosen from the merge reader's hard-live-docs snapshot (ReadersAndUpdates.java:1092-1108).
Trade-offs
- By reading only; it changes when the documents disappear (at the delete, not at the merge).
Apache Solr main ships a merge wrapper that drops documents (AuxIndexJoinMergePolicy, apache/solr#4749) but does not use soft deletes, so it is not affected; other downstream merge policies were not surveyed.
A bounded-exhaustive test that composes merge-policy wrappers checked its own oracle with a deliberately broken wrapper that drops one live document; in every cell IndexWriter's assertion "carry-over soft-deletes must be positive" fired instead. Reading mergeMiddle found the shortcut, and a 584-case exhaustive run on the real code showed exactly when it fails.
The method in full185 words
Method: a bounded-exhaustive differential and model-based test (semi-formal tier) of merge-policy wrapper compositions: 50 wrapper towers x 5 base policies x 10 triggers x 3 data profiles x 2 seeds, checked against a model oracle.
- To check that its oracle can fail, the harness runs mutants. The mutant that wraps merges to drop one live document was meant to be caught by the model's live-documents check; instead IndexWriter's own assertion "carry-over soft-deletes must be positive" fired in every cell.
- Reading
IndexWriter.mergeMiddlelocated the no-hard-deletes subtraction. - A second bounded-exhaustive run on the real code (584 combinations, reviving a hard-deleted document excluded: segments of 1-3 documents, each live, soft-deleted or hard-deleted, and a wrapper that keeps, removes or revives each) failed in exactly the 103 cases with no hard deletes and a removed live document, and in 0 of 481 others; with the fix, 0 of 584.
- Found 2026-10-27 on main
a9779ef3c193; then checked by an independent reproducer, a devil's-advocate verifier, reviews by a model from another family (OpenAI GPT-5.6) that ran their own tests, an adjudication, a follow-up (history, prior reports, security) and an audit.
No prior report found (2026-10-27): GitHub issues, PRs and commits, JIRA, the mailing lists, Elasticsearch, OpenSearch and Solr, and the changed files of all 410 open PRs. Open PR #16571 avoids the same shortcut in its new merge path, with a comment saying why, but does not report or change this one.
Searches4 searches
Verdict new, with one judgement call (#16571). Searches cannot prove absence.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits | 2026-10-27 | four runs of the hunt's prior-report tool plus manual searches: 56 issue and PR queries and 6 commit queries (class and method names, every symptom string, LUCENE-8569, phrasings such as "drop documents at merge time") | no report; origin: LUCENE-8569 / #9615 and apache/lucene-solr#502; related, other paths: #9428 (LUCENE-8381), #9504, #9340, #9431; #16847 only renames a method on that line |
| Open apache/lucene PRs | 2026-10-27 | changed files of all 410 open PRs; the 16 that touch IndexWriter.java grepped for the carry-over code | #16571 (open): its partitioned-merge path counts with countSoftDeletes because "the single-output shortcut (softDelCount - numDeletedDocs) cannot be used here"; adjacent, it leaves the existing path unchanged. |
| JIRA archive; dev@, java-user@, issues@, commits@ | 2026-10-27 | 16 JIRA queries and 72 list queries | no report |
| Elasticsearch, OpenSearch, Solr; the web | 2026-10-27 | 12 downstream queries (exact symptom strings) and 6 web queries | no report |
Introduced by LUCENE-8569 (2018), an optimization that replaced counting with the subtraction: first released in 7.7.0 (backport) and 8.0.0. Executed: 7.4.0-7.6.0 pass, every executed release from 7.7.0 to 10.5.2 and main fail, and reverting only that change makes 7.7.0 and 8.0.0 pass. Never fixed.
Versions tested6 rows
Introduced by LUCENE-8569 "Never count soft-deletes if reader has no hard-deletes" (@s1monw, apache/lucene-solr#502, migrated issue #9615): main line 56cb42d200dd (2018-11-20/21, first released in 8.0.0) and the branch_7x backport 8f76c1b0ca64 (first released in 7.7.0). Before it, mergeMiddle counted the surviving soft-deleted documents of every wrapped reader (7.4.0 with the LUCENE-8341 backport 21f03a49532, 7.5.0 and 7.6.0 with the LUCENE-8381 backport c65fb8568a9). Soft deletes first shipped in 7.4.0 (LUCENE-8233), so no earlier release can build the trigger.
Releases not executed (7.7.1-7.7.2, 8.1.0-8.11.3, 9.1.0-9.12.2, 10.1.0-10.5.1) are affected by the shortcut's assertion string in IndexWriter.class of every release jar from 7.7.0 to 10.5.2 (absent in 7.4.0-7.6.0) and, except 8.8.2-8.11.x (not tagged in the history clone), by tag containment. Upstream main a95c754ee003 and branch_10x still have the subtraction (read, not run).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | compact JUnit 4 of 6 fail through gradle, assertions on and off; controls pass |
| 10.0.0, 10.5.2, 9.12.3 | affected | invalid softDelCount=-1; retention: count 3 for 2 live; next merge throws |
| 7.7.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0 | affected | invalid softDelCount=-1 (maxDoc=2); retention numDocs 3 for 2 live |
| 7.4.0, 7.5.0, 7.6.0 | not affected | pass: every wrapped segment is counted |
| 7.7.0 and 8.0.0 with only LUCENE-8569 reverted | bisect | pass with assertions on and off |
fix (scratch worktree at a9779ef3c193) | not affected | repros 10 of 10 pass, assertions on and off |
An independent reproducer, a devil's-advocate verifier and reviews by a model from another family (OpenAI GPT-5.6) each reproduced it with their own tests; an adjudication confirmed it with high confidence, and the registration check and an audit re-ran the tests and kept it.
Checks and verdicts409 words
Each executing check wrote or re-ran its own JUnit on the stock code; every control passed.
| Check | Verdict | What it found |
|---|---|---|
| independent reproducer | real | Own public-API test outside org.apache.lucene: 4 of 6 fail on main, 10.5.2 and 9.12.3 with assertions on and off; both controls pass; 6 of 6 pass with the fix. Corrected the claim: on 10.x and main, CheckIndex's default level does not flag it, level 2 does. |
| devil's advocate | real; no intended or misuse argument holds | Probe indexing every document with softUpdateDocument: passes with one unrelated hard delete, fails without it, on main and 10.5.2. Found the LUCENE-8569 review remark and Solr's document-dropping wrapper. |
| cross-model reviews (refute and intended lenses) | real, high (both) | An independent reviewer from another model family (OpenAI GPT-5.6) wrote and ran its own tests in each lens, on the earlier main ea57360912ed (its IndexWriter, SegmentCommitInfo and SoftDeletesRetentionMergePolicy are identical to a9779ef3c193): they fail there and pass with the fix; the intended-lens test also fails on 10.5.2 and 7.7.0, and its notes record a pass on 7.6.0. Root cause agreed; the intended-behaviour case rated weak. The refute lens did not re-run the retention variant. |
| adjudication | confirmed, high | Re-ran the independent reproduction's test with a new seed: 4 of 6 fail with assertions on and off; the controls pass. |
| Registration check and audit | reproduces; keep | Re-ran the independent reproduction's test (new seeds), the finder's test and the intended-behaviour check's probe on stock main (same failures) and the independent reproduction's test with the fix (6 of 6 pass). |
| this doc's gradle runs | reproduces; fix validated | Compact JUnit and the finder's test through gradle on stock main (fail) and with the fix (pass), assertions on and off. With the fix: the full lucene/core test suite passes (750 classes, 8,899 tests, 322 skipped, 0 failures, assertions on, including both repro classes and the soft-delete test classes); :lucene:core:ecjLint and :lucene:core:checkGoogleJavaFormat pass. |
| fact checks of this doc | bug holds; 2 + 5 errors fixed | An independent fact check re-ran the gradle commands, the fix and the full lucene/core suite (same results) and found two errors, both fixed: the failed merge closes the IndexWriter (it is not a retryable exception), and one reader citation. A cross-model fact check (OpenAI GPT-5.6) found five smaller errors, fixed, and rejected one of its own counts. A diagram check found one unqualified assertions-off outcome, fixed and re-rendered. |
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 525 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,603 words
- Registry title
- With a soft-deletes field configured, IndexWriter's merge assumes OneMerge#wrapForMerge only revives documents: for each merged segment without hard deletes, every live document that a merge wrapper hides (which the javadoc allows) lowers the new segment's soft-delete count by one, so the merge throws 'invalid softDelCount=-1' when nothing is revived, and under SoftDeletesRetentionMergePolicy commits a too-low count (numDocs and IndexSearcher#count over-count, CheckIndex rejects it, later merges of it throw); regression since 7.7.0 (LUCENE-8569)
- Area
- index / IndexWriter merge soft-delete accounting (mergeMiddle carry-over of soft deletes x OneMerge#wrapForMerge, OneMergeWrappingMergePolicy, SoftDeletesRetentionMergePolicy)
- Priority
- Medium, rank 166: Since 7.7, core IndexWriter soft-delete accounting goes negative or too low when an in-contract merge wrapper hides live documents, which damages the index for good, but it needs soft deletes plus a custom document-dropping wrapper that no known user runs, so it closes the rare-config index-damage cluster after LFV-580.
- Impact class
- silent wrong results
- Impact note
- Only applications that set a soft-deletes field and install their own merge policy whose OneMerge#wrapForMerge hides live documents (for example dropping expired documents at merge time through the @lucene.experimental OneMergeWrappingMergePolicy; no in-tree policy does this, so default configurations are unaffected) are hit, deterministically and at any index size, whenever a merge covers a segment that has no hard deletes and the wrapper hides a live document in it: the merged segment's soft-delete count comes out too low by the number of such hidden documents. With -ea the merge fails at IndexWriter's assertion 'carry-over soft-deletes must be positive' as soon as one segment's carry-over is negative; with -da (as in production) the merge throws IllegalArgumentException 'invalid softDelCount=-1' when the total is negative (for example nothing soft-deleted), and because the arithmetic is deterministic a retry of the same merge fails the same way [read]; when soft-deleted documents are retained, as with SoftDeletesRetentionMergePolicy over such a wrapper, the merge instead commits the too-low softDelCount silently, so DirectoryReader#numDocs, IndexSearcher#count(MatchAllDocsQuery) and IndexWriter#getDocStats().numDocs over-count while searches still return only the live documents, CheckIndex at level 2 or higher reports 'actual soft deletes: 1 but expected: 0', and once the retention query stops matching the next merge of that segment throws 'invalid softDelCount=-1 (maxDoc=3)'; every release from 7.7.0 to 10.5.2 and main [exec] and, by reading, 10.6.0.
- Summary
- You set IndexWriterConfig#setSoftDeletesField and a merge policy whose OneMerge#wrapForMerge hides expired documents (its javadoc says a wrapper may 'add/remove fields or documents'), index two segments without hard deletes, and call forceMerge(1); you expect the merge to drop the expired document and succeed, as the same merge does without a soft-deletes field, or with one when the segment happens to hold a hard delete. Instead IndexWriter derives the carried-over soft deletes as getSoftDelCount() - wrappedReader.numDeletedDocs(), which counts each hidden live document as a dropped soft delete, so the count comes out -1 and the merge throws IllegalArgumentException 'invalid softDelCount=-1 (maxDoc=2)'. With SoftDeletesRetentionMergePolicy on top (one retained soft-deleted document) the merge silently commits softDelCount=0, so numDocs and IndexSearcher#count report 3 where 2 documents are live, CheckIndex reports 'actual soft deletes: 1 but expected: 0', and after the retention window the next merge of that segment throws 'invalid softDelCount=-1 (maxDoc=3)'.
- Introduced
- 56cb42d200dd999342c4cd5c92fbfa271ba91153 on the main line (LUCENE-8569 'Never count soft-deletes if reader has no hard-deletes', apache/lucene-solr#502, migrated issue apache/lucene#9615, by s1monw, approved by mikemccand and jpountz; first released in 8.0.0) and its branch_7x backport 8f76c1b0ca64cd65e05330de9379fed44cfe6a8f (first released in 7.7.0) [exec: commit isolation, 7.7.0 and 8.0.0 jars with only IndexWriter replaced by that release's source minus this commit pass 3 of 3 with -ea and -da, the stock jars fail 2 of 3; read: git blame with .git-blame-ignore-revs and git log -L in the full-history clone list only this commit plus the LUCENE-9570 reformat 2d6ad2fee6d]. Before it (7.4.0 to 7.6.0) mergeMiddle counted the surviving soft-deleted documents of every wrapped reader (code from LUCENE-8341 fe83838ec37 and LUCENE-8381 0ccd5d580d1), which is right for any wrapper (2018-11-20 (authored; committed 2018-11-21; the branch_7x backport the same days))
- First affected version
- 7.7.0 [exec] (branch_7x backport); 8.0.0 [exec] is the first affected main-line release; 7.4.0, 7.5.0 and 7.6.0 pass [exec]; soft deletes first shipped in 7.4.0 (LUCENE-8233), so no earlier release can build the trigger
- Affected versions
- Every release from 7.7.0 through 10.6.0 (7.7.0-7.7.3 and every 8.x, 9.x and 10.x release; 10.6.0 by reading) and main a9779ef3c193 (unreleased 11.0); never fixed. [exec] 7.7.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main with -da, and 7.7.0, 8.0.0, 9.12.3, 10.5.2 and main with -ea: the same failures everywhere, the control passes (repro/src-pre9 variant before 9.1.0). [read] the other releases by tag containment of 8f76c1b0ca6 or 56cb42d200d and by the shortcut's assertion string in IndexWriter.class of every release jar 7.7.0-10.5.2 (absent in 7.4.0-7.6.0); 8.8.2-8.11.4 were released from the old lucene-solr branch_8x, covered by the bytecode marker and the 8.11.4 run. Upstream main a95c754ee003 (2026-10-26), branch_10x 657014b3c579 and branch_10_6 still contain the subtraction [read, not executed]. Executed on aarch64 Linux with JDK 25 only
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check; also reached by code reading
- Upstream and prior reports
- none found (searched 2026-10-27, read-only; the prior-report record, verdict new): the finder's 51-query scripted prior-report search plus three follow-up scripted prior-report searches and manual searches (totals in the prior-report record: 56 GitHub apache/lucene queries, 6 commit queries, 12 downstream queries over Elasticsearch, OpenSearch and Solr, 72 mailing-list queries over dev@, java-user@, issues@ and commits@, 16 JIRA queries, 6 web queries, and the changed files of all 410 open apache/lucene PRs). No issue, PR, JIRA entry, list thread or downstream report says that mergeMiddle's no-hard-deletes soft-delete carry-over is wrong when OneMerge#wrapForMerge hides live documents, and none of the symptom strings ('invalid softDelCount', 'carry-over soft-deletes must be positive', 'soft-deletes count mismatch', CheckIndex 'actual soft deletes: N but expected: M') appears in a report. Origin, not a report: LUCENE-8569 (#9615, closed 2018) and apache/lucene-solr#502, whose review turned the negative carry-over IllegalStateException into an assert ('This means we have a bug right?'). Adjacent: open PR #16571 (jimczi, 'Let a merge write more than one segment') counts soft deletes with countSoftDeletes in its new partitioned-merge path, with a code comment that the single-output shortcut cannot be used there because numDeletedDocs on a range-restricted reader also counts documents of the other outputs; it states the shortcut's precondition for its own reader but neither reports nor changes the existing path for user wrappers. Related, different paths: #9428 (LUCENE-8381, the hard-deletes branch, which counts correctly), #9504 (LUCENE-8458), #9340 (LUCENE-8293), #9431 (LUCENE-8384); #16847 (open draft) only renames numDeletedDocs on that line. Not fixed upstream: the subtraction is unchanged on main a95c754ee003 and branch_10x 657014b3c579. Searches cannot prove absence (GitHub search returns the first 50 results per query; two broad queries were truncated and their plausible titles read)
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26): a compact public-API JUnit and the finder's JUnit through gradle in a scratch worktree, stock and with the fix, with assertions on and off, and the fulllucene/coretest suite with the fix (2026-10-27); repro runs by the finder, an independent reproducer, a devil's-advocate verifier, cross-model reviews (on the earlier mainea57360912ed, identical in the files involved), the adjudication, the registration check and an audit; a diagram probe of the writer's state after the failed merge (assertions on and off, 2026-10-27); release jars 7.4.0, 7.5.0, 7.6.0, 7.7.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2, with commit isolation on 7.7.0 and 8.0.0; a 584-case bounded-exhaustive run on main; independent fact checks of this doc, one of which re-ran the gradle commands, the fix and the fulllucene/coresuite. OpenJDK 25.0.3, Linux aarch64.
- Finding record
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5495-5536 (at a9779ef3c193) (mergeMiddle: wrapForMerge applied at :5497; with hard deletes (:5504) the surviving soft deletes are counted by countSoftDeletes; without hard deletes (:5531-5535) carryOverSoftDeletes = getSoftDelCount() - wrappedReader.numDeletedDocs() with the asserts 'carry-over soft-deletes must be positive' and assertSoftDeletesCount), :5427-5450 (countSoftDeletes), :5593 (merge.info.setSoftDelCount)lucene/core/src/java/org/apache/lucene/index/SegmentCommitInfo.java:384-388 (at a9779ef3c193) (setSoftDelCount throws IllegalArgumentException 'invalid softDelCount=... (maxDoc=...)' for a negative count)lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:301-309 (at a9779ef3c193) (OneMerge#wrapForMerge: 'Wrap a reader prior to merging in order to add/remove fields or documents.'; only doc-ID reordering is illegal)lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:551-555 (at a9779ef3c193) (setSoftDeletesField: 'Soft deletes allow to retain documents across merges if the merge policy modifies the live docs of a merge reader')lucene/core/src/java/org/apache/lucene/index/OneMergeWrappingMergePolicy.java:27 (at a9779ef3c193) and SoftDeletesRetentionMergePolicy.java:44 (@lucene.experimental)lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:4941-4947 (at a9779ef3c193) (soft-delete check: 'actual soft deletes: N but expected: M')SECURITY.md (line 28 threat model, line 42 configuration trusted, line 50 in-scope reports)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5427-5452 (countSoftDeletes), 5495-5536 (mergeMiddle soft-delete accounting), 5593 (setSoftDelCount)lucene/core/src/java/org/apache/lucene/index/SegmentCommitInfo.java:384-388lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:301-309lucene/core/src/java/org/apache/lucene/index/PendingDeletes.java:242-250; lucene/core/src/java/org/apache/lucene/search/MatchAllDocsQuery.java:57-58lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:499, 1114, 4673, 4741-4757, 4930-4960lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5109-5113 (catch -> tragicEvent), 6090-6105 (tragicEvent, maybeCloseOnTragicEvent -> rollbackInternal), 2286-2289 (forceMerge after a tragedy), 6824-6830 and 891-902 (getDocStats)lucene/core/src/java/org/apache/lucene/index/SoftDeletesDirectoryReaderWrapper.java:142-147, 154-186; SegmentReader.java:93-108
- Who is affected
lucene/core/src/java/org/apache/lucene/index/SoftDeletesRetentionMergePolicy.java:60-72; IndexWriter.java:4059-4062; lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:127-130lucene/core/src/java/org/apache/lucene/index/OneMergeWrappingMergePolicy.java:27
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:301-309; IndexWriterConfig.java:545-556; IndexWriter.java:4842-4845; CheckIndex.java:4945-4947
- Suggested fix
lucene/core/src/java/org/apache/lucene/index/SoftDeletesRetentionMergePolicy.java:96-140; IndexWriter.java:5427-5452, 5499-5500gradle/validation/ecj-lint/ecj.javadocs.prefs:146 (unusedPrivateMember=error)lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:1092-1108 (getReaderForMerge: hard-live-docs snapshot)
- TestSoftDeletesMergeWrapperDropsDoc.java (JUnit test):
repro/LFV-730/src/org/apache/lucene/index/TestSoftDeletesMergeWrapperDropsDoc.java(100 lines)
A step-by-step picture made in parallel with this doc from a probe run on main a9779ef3c193 with assertions on and off (re-run on 2026-10-27 to show the writer's state after each failed merge); the values on screen come from the probe's trace.
assets/LFV-730/bug.png(525 KB)
