A soft delete marks a document in a doc-values field instead of a live-docs file. For committed segments, IndexWriter#hasDeletions() looks only for a live-docs file, so a writer that has just opened the index, and has loaded none of its segments yet, says "no deletions" while its own getDocStats() counts them.
- A hard delete (
deleteDocuments) marks the document in the segment's live-docs file. A soft delete gives the document a value in a doc-values field you name withIndexWriterConfig#setSoftDeletesField;softUpdateDocumentadds the new version and soft-deletes the old one in one call. - The writer treats soft-deleted documents as deleted: its NRT readers hide them and merges reclaim them. Since 7.4.0 each commit stores a per-segment soft-delete count next to the hard-delete count; a soft delete writes no live-docs file.
- The writer's reader pool holds the segments it has loaded, for example to open an NRT reader or to apply deletes. A freshly opened writer has loaded none.
hasDeletions(): nothing buffered, nothing pooled, and the committed segment has no live-docs file, so it answers false while getDocStats() counts the soft-deleted document. Select the still to open it at full size. Generated with AI, checked against the code.- Configure a soft-deletes field (
setSoftDeletesField("soft"),NoMergePolicy), add documentid=1, thensoftUpdateDocumentit: 2 documents on disk, 1 of them soft-deleted. Close the writer; it commits. - Open a new
IndexWriteron the index, as after any restart. ItsgetDocStats()says maxDoc 2, numDocs 1: it counts the deleted document. - You expect
hasDeletions()to be true: its javadoc says "Returns true if this index has deletions", and the writer treats a soft-deleted document "as if it has been hard-deleted". - Instead it returns false. Its checks of buffered changes and of the segments in the writer's reader pool count soft deletes, but a reopened writer has neither; its last check, over committed segments, only asks whether a live-docs file exists, and a soft delete never writes one.
- With
NoMergePolicyon the reopened writer too, openDirectoryReader.open(writer)and ask again: now true, though the index files did not change.addIndexes(Directory)of such a segment into another writer gives the same false.
Applications that configure a soft-deletes field and call IndexWriter#hasDeletions() after a writer restart or addIndexes(Directory) while committed soft-deleted documents are still in the segments: deterministic. Without soft deletes the answer is right. Nothing in Lucene calls the method, so searches, merges, live docs and index files are unaffected, and getDocStats() and NRT readers count correctly.
Mechanism, terms and numbers616 words
IndexWriter#hasDeletions() returns true if buffered changes or segments in the writer's reader pool have deletions (these checks count soft deletes), and otherwise if any committed segment has SegmentCommitInfo#hasDeletions(), which is delGen != -1: the segment has a live-docs file, so only hard deletes count. A soft delete never writes a live-docs file; since 7.4.0 the commit records it as a per-segment soft-delete count instead. A writer opened on an existing index has nothing buffered and nothing pooled, so with a soft-deletes field configured and only soft deletes committed it answers false, while its own getDocStats() counts the deleted documents (maxDoc 2, numDocs 1 in the repro). The answer turns true, with no change to the index, once DirectoryReader.open(writer) loads the segment into the pool (reopened writer with NoMergePolicy; with the default merge policy that open merges the soft-deleted document away), and addIndexes(Directory) of such segments gives the same false. No Lucene code calls the method, so searches, merges, live docs and index files are unaffected: a minor, deterministic wrong answer in a public statistics method, in every release since 7.4.0 and on main. The one-line fix info.getDelCount(softDeletesEnabled) > 0, the count numDeletedDocs(info) already uses, passes the repro and the whole core test suite.
// IndexWriter.java:1444-1460
/**
* Returns true if this index has deletions (including buffered deletions). Note that this will
* return true if there are buffered Term/Query deletions, even if it turns out those buffered
* deletions don't match any documents.
*/
public synchronized boolean hasDeletions() {
ensureOpen();
if (bufferedUpdatesStream.any() || docWriter.anyDeletions() || readerPool.anyDeletions()) {
return true; // :1451-1452 buffered and pooled: count soft deletes
}
for (final SegmentCommitInfo info : segmentInfos) {
if (info.hasDeletions()) { // :1455 delGen != -1: hard deletes only
return true;
}
}
return false;
}- The commit. One segment
_0: maxDoc 2, hard-delete count 0, soft-delete count 1,delGen-1 (no live-docs file).SegmentCommitInfo#getDelCount(boolean includeSoftDeletes)adds the two counts (SegmentCommitInfo.java:453-455). - The pooled check counts soft deletes.
ReaderPool#anyDeletions()(ReaderPool.java:156-163) is true if a pooled segment hasrld.getDelCount() > 0, andPendingDeletes#getDelCount()isinfo.getDelCount() + info.getSoftDelCount() + numPendingDeletes()(PendingDeletes.java:242-246). A buffered (unflushed) soft-delete update makes the buffered checkdocWriter.anyDeletions()true. - The committed-segment check does not.
SegmentCommitInfo#hasDeletions()isreturn delGen != -1;under the javadoc "Returns true if there are any deletions for the segment at this commit." (SegmentCommitInfo.java:319-322). - A reopened writer has an empty pool. The probe of the reopened writer prints
bufferedUpdatesStream.any()=false docWriter.anyDeletions()=false readerPool.anyDeletions()=false; _0 info.hasDeletions()=false info.getDelCount(true)>0=true, sohasDeletions()returns false. - The same writer counts the delete elsewhere.
numDeletedDocs(info)usesinfo.getDelCount(softDeletesEnabled)for an unpooled segment (IndexWriter.java:891-903,:898), andgetDocStats()sums it (:6824-6834): maxDoc 2, numDocs 1. - Pooling flips the answer. After
DirectoryReader.open(writer)(reopened writer withNoMergePolicy) the segment is pooled,readerPool.anyDeletions()is true andhasDeletions()returns true; the index files and the commit are unchanged. With the default merge policy, the probe saw the segment merged away while the reader opened (new segment_1, maxDoc 1, the soft-deleted document reclaimed), and then false is correct. addIndexes(Directory)copies the segment as it is, without pooling it: the receiving writer reports maxDoc 2, numDocs 1 andhasDeletions()false.
| situation (one soft-deleted document committed) | getDocStats() maxDoc / numDocs | hasDeletions() | expected |
|---|---|---|---|
| a new writer opened on the index (the repro) | 2 / 1 | false | true |
that writer after DirectoryReader.open(writer), NoMergePolicy | 2 / 1 | true | true |
another writer after addIndexes(dir) | 2 / 1 | false | true |
the writer that made the soft delete, before close() | 2 / 2 (still buffered) | true | true |
| control: a hard delete instead, new writer | 2 / 1 | true | true |
Exposure and likelihood241 words
- Who is hit. Applications that configure a soft-deletes field (opt-in,
IndexWriterConfig#setSoftDeletesField) and callIndexWriter#hasDeletions()on a writer opened on an existing index whose committed segments still hold soft-deleted documents (as after a restart), or afteraddIndexes(Directory...)of such segments. No timing and no scale are needed; assertions on or off. - Default config is not affected. Without soft deletes every deletion writes a live-docs file and the committed-segment check is right; the hard-delete control passes on every version run.
- What goes wrong. A public statistics method gives a wrong answer, and the answer for an unchanged index changes when an NRT reader is opened (with
NoMergePolicyon that writer). An application that asks it after a restart whether deletes need attention (reclaiming them, reporting them) is told there are none. - What does not go wrong. No Lucene main code calls
IndexWriter#hasDeletions()(the otherhasDeletions()calls are reader- orSegmentCommitInfo-level). Searches, live docs, merges and index files are unchanged; soft-deleted documents stay hidden from writer readers and fromSoftDeletesDirectoryReaderWrapper. Upstream tests call the method only without soft deletes (TestTryDelete,TestTieredMergePolicy, and anassertinTestFunctionRangeQueryinlucene/queries). - Not affected either: the writer that made the soft delete keeps the segment pooled and answers true, even with
setReaderPooling(false), after a merge that retains the soft-deleted document, and for a document added already soft-deleted. - Impact class: minor (a wrong return value with no in-tree effect); priority low.
One JUnit test on the public API: ByteBuffersDirectory, single thread, deterministic (the seed sets only the test framework's randomization, such as codec, similarity, locale and timezone; seeds DEADBEEF and 5EED1234, 5 iterations, fail the same way). It compares hasDeletions() with the same writer's getDocStats(), so it cannot fail vacuously. Put it in lucene/core/src/test/org/apache/lucene/index/ and run it with gradle.
/*
* 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 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.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestIndexWriterHasDeletionsSoftDeletes extends LuceneTestCase {
public void testCommittedSoftDeleteAfterWriterReopen() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
Document doc = new Document();
doc.add(new StringField("id", "1", Field.Store.NO));
IndexWriterConfig iwc = new IndexWriterConfig().setSoftDeletesField("soft");
iwc.setMergePolicy(NoMergePolicy.INSTANCE);
try (IndexWriter w = new IndexWriter(dir, iwc)) {
w.addDocument(doc);
w.softUpdateDocument(new Term("id", "1"), doc, new NumericDocValuesField("soft", 1));
}
try (IndexWriter w =
new IndexWriter(dir, new IndexWriterConfig().setSoftDeletesField("soft"))) {
IndexWriter.DocStats stats = w.getDocStats();
// expected true like getDocStats() (maxDoc 2, numDocs 1), actual false
assertEquals(
"hasDeletions() vs getDocStats() maxDoc=" + stats.maxDoc + " numDocs=" + stats.numDocs,
stats.maxDoc > stats.numDocs,
w.hasDeletions());
}
}
}
}
# $REPRO holds TestIndexWriterHasDeletionsSoftDeletes.java; $LUCENE is a Lucene checkout
cp $REPRO/TestIndexWriterHasDeletionsSoftDeletes.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE
./gradlew :lucene:core:test --tests TestIndexWriterHasDeletionsSoftDeletes -Ptests.seed=DEADBEEF
- Expected
hasDeletions()returns true, like the writer'sgetDocStats()(maxDoc 2, numDocs 1).- Observed
hasDeletions()returns false:hasDeletions() vs getDocStats() maxDoc=2 numDocs=1 expected:<true> but was:<false>.
More files, environment, full output192 words
NoMergePolicy on the first writer only keeps the soft-deleted document on disk: with the default merge policy a merge reclaimed it before the reopen, and then false is correct (measured while minimizing). The reopened writer uses the default config plus the same soft-deletes field (a writer without it cannot open this index).
The same failure, by the repro harness: main 645d4fe14a20 and the 10.6.0 jars, assertions on and off. The hunt's longer 3-test class (the NRT-reader flip and a hard-delete control) fails the same way on 7.4.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2. Use this one test as the regression test too.
| lucene | main 645d4fe14a20 and 10.6.0: reproduced, assertions on and off; 7.4.0 to 10.5.2: reproduced by the hunt's longer test; 7.3.1: no soft-deletes API |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 |
# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on
TestIndexWriterHasDeletionsSoftDeletes > testCommittedSoftDeleteAfterWriterReopen FAILED
java.lang.AssertionError: hasDeletions() vs getDocStats() maxDoc=2 numDocs=1 expected:<true> but was:<false>
at org.apache.lucene.index.TestIndexWriterHasDeletionsSoftDeletes.testCommittedSoftDeleteAfterWriterReopen(TestIndexWriterHasDeletionsSoftDeletes.java:43)
# repro harness, the same message: main -ea, main -da, 10.6.0 -ea, 10.6.0 -da (1 run, 1 failed each)
# with fix A: the test passes (gradle; harness -ea and -da)- The javadoc promises "true if this index has deletions" and documents only false positives (buffered deletes that match nothing).
setSoftDeletesField: a soft-deleted document "is treated as if it has been hard-deleted through the IndexWriter API"; the method's pooled check andgetDocStats()count it.- LUCENE-8341 made the neighbouring checks soft-aware in 2018 and left this loop; nothing found calls that intended.
The full argument236 words
- Contract.
IndexWriter.java:1445-1447: "Returns true if this index has deletions (including buffered deletions). Note that this will return true if there are buffered Term/Query deletions, even if it turns out those buffered deletions don't match any documents." The only approximation it documents is a false positive. - Soft deletes are deletes for the writer.
IndexWriterConfig.java:544-547: "If a document is marked as soft-deleted, the document is treated as if it has been hard-deleted through the IndexWriter API ... index readers obtained from the IndexWriter will reflect all deleted documents in its live docs."CHANGES.txt:7388-7389(LUCENE-8233): "Soft deletes are accounted for inside the index writer". - The method disagrees with itself. Its buffered and pooled checks count soft deletes, the committed-segment loop does not, so for one unchanged index the answer depends on whether a reader happened to be pooled. No javadoc says so.
- The same writer disagrees.
getDocStats()andnumDeletedDocs(info)count the soft delete for the same unpooled segment. - History: an oversight. LUCENE-8233 (2018) made the writer account for soft deletes and did not touch the method. LUCENE-8341 (2018) changed its pooled check (
readerPool.anyPendingDeletes()toreaderPool.anyDeletions()) and the unpooled branch ofnumDeletedDocs(info.getDelCount()toinfo.getDelCount(softDeletesEnabled)), but left the loop; its commit message says the point is that "the number of document can be read from commit points without opening a full blown reader". The discussions of #9280, #9388 and #9247 never mentionhasDeletions.
The devil's-advocate check209 words
Strongest case for "intended": hasDeletions means hard (live-docs) deletions, like SegmentCommitInfo#hasDeletions() and a plain DirectoryReader.open(dir), which shows soft-deleted documents as live; and the writer does the same on purpose in numDeletesToMerge: // if we don't have a pooled instance let's just return the hard deletes, this is safe! (IndexWriter.java:6454), a fallback a reviewer on #13324 also called deliberate.
Why it fails:
- The method's own pooled and buffered checks already count soft deletes, so "hard only" does not describe the method.
numDeletesToMergereturns a merge-policy-dependent count (a retention policy keeps soft-deleted documents) that needs a reader to compute, so under-counting is its safe direction.hasDeletionsdepends on no policy, its javadoc documents only false positives, and the exact soft count has been in the commit, readable in constant time, since LUCENE-8341.- A writer without the soft-deletes field cannot even open such an index (
IllegalArgumentException), so every writer that can see the segment is soft-aware. - No javadoc, test, CHANGES or MIGRATE entry, issue, PR or list thread found calls this behaviour intended.
What remains are impact arguments (no in-tree caller), not intent arguments. Verdict: not intended, a minor defect; maintainers may still prefer a javadoc change or deprecation (option C) to the one-line fix.
Recommended A. Count soft deletes in the committed-segment loop (one line)
In the loop over committed segments, replace info.hasDeletions() with info.getDelCount(softDeletesEnabled) > 0 (IndexWriter.java:1455), the count numDeletedDocs(info) already uses. It reads the commit's counters and loads no reader. The repro passes, as do the 8 closest suites (233 tests with the repro, assertions on and off) and all 8,898 core tests.
--- a/lucene/core/src/java/org/apache/lucene/index/IndexWriter.java
+++ b/lucene/core/src/java/org/apache/lucene/index/IndexWriter.java
@@ -1452,7 +1452,7 @@ public class IndexWriter
return true;
}
for (final SegmentCommitInfo info : segmentInfos) {
- if (info.hasDeletions()) {
+ if (info.getDelCount(softDeletesEnabled) > 0) {
return true;
}
}All options and trade-offs3 options · 412 words
Two code fixes were run; A is recommended. Workaround for users today: DocStats s = w.getDocStats(); boolean committedDeletes = s.maxDoc > s.numDocs; counts unpooled soft deletes (IndexWriter.java:6824-6834; it does not see buffered, unapplied deletes), or open an NRT reader first.
A. Count soft deletes in the committed-segment loop (one line) (validated by the checks) (recommended)
Use the same expression as numDeletedDocs(info) (IndexWriter.java:898): constant time per segment, reads the hard and soft counts stored in the commit, so it pulls no reader and depends on no merge policy.
--- a/lucene/core/src/java/org/apache/lucene/index/IndexWriter.java
+++ b/lucene/core/src/java/org/apache/lucene/index/IndexWriter.java
@@ -1452,7 +1452,7 @@ public class IndexWriter
return true;
}
for (final SegmentCommitInfo info : segmentInfos) {
- if (info.hasDeletions()) {
+ if (info.getDelCount(softDeletesEnabled) > 0) {
return true;
}
}Trade-offs
For hard deletes A asks delCount > 0 where the old code asked delGen != -1. The writer writes a live-docs file only with pending deletes (PendingDeletes.java:171-172), and SegmentReader asserts that no live-docs file means delCount == 0 (SegmentReader.java:100-105); the suites below found no difference. If a reviewer wants no change at all for hard deletes, take B.
What the checks validated
- Scratch worktree of main
645d4fe14a20(removed afterwards), seed DEADBEEF: stock, the test fails through gradle; with A it passes. - The repro and the 8 closest suites with A,
-Ptests.asserts=trueandfalse:TestIndexWriterHasDeletionsSoftDeletes,TestTryDelete,TestTieredMergePolicy,TestSoftDeletesRetentionMergePolicy,TestSoftDeletesDirectoryReaderWrapper,TestPendingSoftDeletes,TestIndexWriterDelete,TestAddIndexes,TestIndexWriter: 233 tests, 8 skipped, 0 failures each time. - All of
:lucene:core:testwith A (assertions on): 8,898 tests in 749 classes, 322 skipped, 0 failures. - Earlier, with A shadowed in: the hunt's 3 tests, the independent reproducer's 8 and the 6 written for a cross-model review all pass.
if (info.hasDeletions() || (softDeletesEnabled && info.getSoftDelCount() > 0)). Behaviour without soft deletes is unchanged by construction.
- if (info.hasDeletions()) {
+ if (info.hasDeletions() || (softDeletesEnabled && info.getSoftDelCount() > 0)) {Trade-offs
Slightly longer; a second notion of "has deletions" next to numDeletedDocs.
What the checks validated
The repro passes with B shadowed in, assertions on and off (repro harness). Not run through the suites.
Document that committed soft deletes count only once the segment is pooled, or deprecate the method (no in-tree caller).
Trade-offs
Leaves the method inconsistent with getDocStats() and with its own pooled check; not recommended.
Downstream, a public code search found no IndexWriter#hasDeletions() caller in current Solr, Elasticsearch or OpenSearch, and none of the eight caller repositories it found configures a soft-deletes field.
Model-based randomized testing of soft deletes: random operation scripts on the real jars, checked after every step against an exact executable model of live, soft-deleted and hard-deleted documents. After each writer reopen it compares hasDeletions() with the model; the mismatch fired 1,341 times in 1,500 scripts and was reduced to one public-API JUnit test.
The method in full138 words
- Method (non-formal): model-based randomized testing in a hunt unit on soft deletes. Random scripts of soft updates, doc-values soft deletes, hard deletes, flushes, NRT reopens, commits, forced merges,
addIndexes, writer reopens and rollbacks ran on the stock jars; after each observation an exact executable reference model of live, soft-deleted, hard-deleted and retained documents was compared with the writer. - Signal. After a writer reopen the model expects
hasDeletions()true whenever a deleted document is committed; the note "hasDeletions-after-reopen expected=true got=false anyHard=false" fired 86 times in the first 150 scripts and 1,341 times in 1,500. A later bounded-exhaustive run hit it too. - Reduction. The finder wrote a public-API JUnit (3 tests, one control); it was later minimized to the one test above.
- The finder labelled it semi-formal; the registry records non-formal, because the first signal came from random scripts.
No prior report: 167 queries over GitHub, the JIRA archive, the mailing lists and the web (2026-10-26), refreshed on GitHub 2026-10-31. Closest is #13324 (2024, closed unmerged), the same blind spot in numDeletesToMerge.
Searches5 searches
Nearest items, none this bug: #13324 "Fix numDeletesToMerge for unchanged segments" (same trigger, another method, its diff never touches hasDeletions); #9388 / LUCENE-8341 and #9280 / LUCENE-8233 (origin and contract); #9300 / LUCENE-8253 (added the numDeletesToMerge fallback); #9377 / LUCENE-8330 (MergeContext's hasDeletions, another method); #11428 / LUCENE-10392 (open redesign that would remove the hard/soft split). Open PR #16948 edits IndexWriter.java but no hasDeletions line. Limits: GitHub search is capped per query; no search proves absence.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-26 | three runs of the scripted prior-report search (16 issue/PR queries each) plus own searches for hasDeletions with soft-delete terms; every issue or PR updated since 2026-10-01 | no report; #13324 closest (related) |
| Open PRs | 2026-10-26 | all 408 open PRs listed with their changed files | 19 touch the four files; none adds or removes a hasDeletions line |
| Commits, JIRA archive, downstream code | 2026-10-26 | 3 commit, 4 JIRA and 2 downstream queries per run; full git history of the method | LUCENE-8233 and LUCENE-8341 (origin); no report |
| Mailing lists (dev@, java-user@, issues@, commits@) and the web | 2026-10-26 | 20 list queries per run, 7 web queries | no report; a 2012 thread asks for javadoc (before soft deletes); the 2021 numDeletesToMerge thread is about another method |
| GitHub refresh | 2026-10-31 | 16 issue/PR and 3 commit queries, 260 hits; gh pr diff 16948 | 27 hits mention hasDeletions in a body or comment, all about other methods or older behaviour; none reports this soft-delete behaviour; items updated since 2026-10-26 concern other code |
Introduced in 7.4.0 by LUCENE-8233 (2018), which made the writer count soft deletes; LUCENE-8341 updated the neighbouring checks and missed this loop. Every release since fails; 7.3.1 has no soft-deletes API. Not a regression.
Versions tested5 rows
Introduced by LUCENE-8233 "Add support for soft deletes to IndexWriter delete accounting" (ecc17f9023309, 2018-04-04, issue #9280; a JIRA-era patch), which made soft deletes count as deletes inside the writer without touching hasDeletions. Missed fix: LUCENE-8341 "Record soft deletes in SegmentCommitInfo" (fe83838ec37, committed 2018-06-04, branch_7x 21f03a49532, #9388) made the pooled check and numDeletedDocs soft-aware and left the loop. Both first ship in 7.4.0, so execution cannot separate them. The loop line dates from 2011 (LUCENE-3084) and was correct while only hard deletes existed. Unchanged on upstream main 2f424fd428da and in 10.6.0.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the one test fails (gradle; harness -ea and -da) |
| 10.6.0 | affected | the one test fails (-ea and -da) |
| 10.5.2 | affected | the hunt's 3-test class: 2 of 3 fail (-ea and -da); control passes |
| 7.4.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 | affected | the hunt's 3-test class (API-adapted for 7.x, 8.x and 9.0.0): 2 of 3 fail; control passes |
| 7.3.1 | API absent | no soft-deletes API: the test does not compile; a hard-delete control passes |
An independent reproducer, an intended-behaviour devil's advocate, an adjudication (confirmed, high) and a credibility audit re-ran a repro and found it real. A cross-model reviewer (OpenAI GPT-5.6, reading only) agreed on the root cause in both reviews: real once, and "not real" once on procedure, as it could not run code.
Checks and verdicts226 words
Each check had its own lens. The reproducer, the devil's advocate, the adjudication and the audit ran repros themselves; for the read-only cross-model reviews a separate run re-ran the hunt's repro (it failed both times).
| Check | Verdict | What it found |
|---|---|---|
| independent reproducer | real | Own 8-test class fails 4 of 8 on main, 10.5.2, 9.12.3 and 8.11.4, controls pass, 8 of 8 with the fix; found the addIndexes(Directory) trigger and corrected the reach (the writer that made the delete answers true even with pooling off). |
| cross-model review, refute lens | not real (procedural, low) | Read-only, could not run code; on reading it agreed on the root cause, called the intended argument weak and the evidence "a genuine minor API-consistency defect". |
| cross-model review, works-as-designed lens | real, medium | Intended argument weak; a separate 6-test class for this review showed addIndexes(Directory) failing and same-writer variants answering true; 6 of 6 with the fix. |
| intended-behaviour devil's advocate | real | Built four works-as-designed readings and rejected all; 6 fact probes on main and 10.5.2. |
| adjudication | confirmed, high | Re-ran the reproducer's suite: 4 of 8 fail with assertions on and off, 8 of 8 pass with the fix; impact class minor. |
| credibility audit | keep | Re-ran the hunt's repro with fresh seeds: fails 2 of 3 (assertions on and off), passes with the fix. |
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 476 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,402 words
- Registry title
- IndexWriter#hasDeletions ignores committed soft deletes in segments the writer has not pooled, so a writer reopened on an index whose only deletions are soft deletes (or one that took such segments through addIndexes(Directory)) answers false, and true once an NRT reader is opened
- Area
- index / IndexWriter statistics (soft deletes)
- Priority
- Low, rank 873: IndexWriter#hasDeletions always answers false after a restart when the only deletions are committed soft deletes, but no Lucene code calls it and getDocStats and NRT readers give the right answer.
- Impact class
- minor
- Impact note
- Only applications that configure a soft-deletes field and call IndexWriter#hasDeletions are hit, and only for segments the writer has not pooled: after a writer is opened on an existing index whose only deletions are committed soft deletes (any restart), or after addIndexes(Directory) of such segments; the same writer's getDocStats() and NRT readers count the deletes, and the answer turns true once DirectoryReader.open(writer) pools the segment; no Lucene code calls the method, so no search result, merge decision or index file changes; opt-in feature, any index size, every release since 7.4.0.
- Summary
- You configure a soft-deletes field, soft-delete a document (softUpdateDocument, or updateDocValues on that field), commit, close, and open a new IndexWriter on the index; you expect hasDeletions() to return true, because its javadoc says 'Returns true if this index has deletions', IndexWriterConfig#setSoftDeletesField says a soft-deleted document 'is treated as if it has been hard-deleted through the IndexWriter API', and the same writer's getDocStats() counts the deleted document (maxDoc 2 and numDocs 1 in the repro). Instead it returns false, with -ea and -da, until DirectoryReader.open(writer) puts the segment into the reader pool; after that the same call on the unchanged index returns true. The loop over committed segments uses SegmentCommitInfo#hasDeletions() (delGen != -1, hard deletes only), while the buffered and pooled branches of the same method count soft deletes; the one-line fix is info.getDelCount(softDeletesEnabled) > 0.
- Introduced
- ecc17f9023309ca2c46eaf65fd031e4af0ef5a25 (LUCENE-8233 'Add support for soft deletes to IndexWriter delete accounting', now GitHub issue #9280; a JIRA-era patch with no apache pull request, reviewed on the author's fork; branch_7x backport 02fa5a0b059353ae5607e91211cea24974257980), which made soft deletes count as deletes inside the writer without touching hasDeletions. Missed fix: fe83838ec3768f25964a04510cd10772cf034d34 (LUCENE-8341 'Record soft deletes in SegmentCommitInfo', #9388; branch_7x 21f03a49532d), which made the pooled branch (anyPendingDeletes() -> anyDeletions()) and numDeletedDocs(info) soft-aware but left the 'if (info.hasDeletions())' loop, which dates from 2011 (LUCENE-3084, 4fb8aa0d4e7; SegmentCommitInfo#hasDeletions from 2012, LUCENE-4055, 7262b340ba9) and was correct until soft deletes existed [read: git log -L, git blame and git merge-base in the full-history clone; exec: 7.3.1 lacks the API, 7.4.0 fails; both commits first ship in 7.4.0, so execution cannot separate them] (2018-04-04 (LUCENE-8233); missed fix LUCENE-8341 authored 2018-05-26, committed 2018-06-04)
- First affected version
- 7.4.0 [exec] (the first release with IndexWriterConfig#setSoftDeletesField); 7.3.1 is not affected [exec: the soft-deletes API is absent, compile error; a hard-delete control passes]. Not a regression.
- Affected versions
- every release from 7.4.0 through 10.6.0 and main ea57360912ed [exec: the one-test repro fails on the 10.6.0 jars with -ea and -da (2026-10-31); the finder's JUnit fails 2 of 3 on 7.4.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), with the hard-delete control passing on every target; the independent reproduction's JUnit fails 4 of 8 on main (-ea and -da), 10.5.2, 9.12.3 and 8.11.4] [read: no semantic change to the method after LUCENE-8341]; unchanged on upstream main 49483e82b357, branch_10x and branch_10_6 (the prior-report record, 2026-10-26), and the same at the 10.6.0 release commit 4ae946e5a9b [read, 2026-10-30].
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-26, read-only; the prior-report record): three runs of the scripted prior-report search (each 16 GitHub issue and PR queries on apache/lucene, 3 commit, 2 downstream-repository, 20 mailing-list queries over dev@, java-user@, issues@ and commits@, and 4 JIRA-archive queries, plus web queries: complete in one run, partly pending in the other two) and manual reads: 167 queries, 19 hits read, none the same bug; the finder's, the independent reproduction's, the cross-model review's and the intended-behaviour check's searches found nothing either (up to 661 hits). Not fixed upstream: upstream main 49483e82b357, branch_10x and branch_10_6 still have info.hasDeletions() in the loop; the last commit to IndexWriter.java on main (169cbffd12f4, #16581) does not touch it; no open PR changes hasDeletions. Closest related, not the same bug: #13324 'Fix numDeletesToMerge for unchanged segments' (GitHub dnhatn, opened 2024-04-28, closed unmerged 2024-10-29): the same blind spot (a reopened writer, committed soft deletes, a segment not in the reader pool, a hard-only fallback) in IndexWriter#numDeletesToMerge; its diff changes only numDeletesToMerge and a test, and neither it nor its comments mention hasDeletions (re-checked read-only 2026-10-26); a reviewer there called the numDeletesToMerge fallback deliberate (the count depends on the merge policy, and the writer should not pull readers for segments not in memory), reasons that do not apply to hasDeletions. Also related: #9388 / LUCENE-8341 (the missed fix), #9280 / LUCENE-8233 (the contract), #9300 / LUCENE-8253 (added the numDeletesToMerge fallback), #9377 / LUCENE-8330 (a hasDeletions on MergeContext, a different method), #11428 / LUCENE-10392 (open redesign that would remove the hard/soft split; does not report this); the 2021 dev@ thread behind LFV-228 is about another method. Gaps: GitHub search is capped per query and rate-limited; downstream code search saw the first 50 results per repository; some web queries stayed pending; keyword searches cannot prove absence; private security reports are invisible.
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the one-test repro through gradle in a scratch worktree (assertions on; removed afterwards) and through the hunt repro harness (assertions on and off); fix A through gradle (the repro and the 8 closest suites, 233 tests, assertions on and off; all of:lucene:core:test, 8,898 tests, assertions on); fix B with the patched class shadowed (assertions on and off). The 10.6.0 release jars with the one-test repro (assertions on and off). The hunt ran on mainea57360912ed(the same lines) with history runs on 7.3.1 (API absent), 7.4.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2. Upstream main2f424fd428da(2026-10-30) has no commit touchingIndexWriter.java,SegmentCommitInfo.java,ReaderPool.javaorPendingDeletes.javasince645d4fe14a20. OpenJDK 25.0.3, Linux aarch64 (the only platform run).
- Finding record
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1444-1460 (at ea57360912ed) (hasDeletions: javadoc 'Returns true if this index has deletions (including buffered deletions)' with buffered Term/Query deletes as the only documented false positive; :1451 buffered, docWriter and readerPool.anyDeletions() branches; :1454-1458 loop with 'if (info.hasDeletions())' at :1455), :888-903 (numDeletedDocs(info): pooled rld.getDelCount(), unpooled info.getDelCount(softDeletesEnabled)), :6824-6834 (getDocStats sums numDeletedDocs), :6454 (numDeletesToMerge unpooled fallback 'let's just return the hard deletes, this is safe!')lucene/core/src/java/org/apache/lucene/index/SegmentCommitInfo.java:319-322 (at ea57360912ed) (hasDeletions() is delGen != -1), :453-455 (getDelCount(boolean includeSoftDeletes) adds getSoftDelCount())lucene/core/src/java/org/apache/lucene/index/ReaderPool.java:153-163 (at ea57360912ed) (anyDeletions: any pooled rld.getDelCount() > 0); PendingDeletes.java:242-246 (getDelCount = info.getDelCount() + info.getSoftDelCount() + numPendingDeletes())lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:541-548 (at ea57360912ed) (setSoftDeletesField: a soft-deleted document 'is treated as if it has been hard-deleted through the IndexWriter API'); lucene/CHANGES.txt:7355-7357 (at ea57360912ed) (LUCENE-8233: 'Soft deletes are accounted for inside the index writer')apache/lucene#13324 (read-only gh pr view and gh pr diff, 2026-10-26: author dnhatn, closed unmerged 2024-10-29; diff touches only IndexWriter#numDeletesToMerge and TestSoftDeletesRetentionMergePolicy; 0 comments mention hasDeletions)
- The finding in 30 seconds
a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1444-1460 (hasDeletions), :891-903 (numDeletedDocs), :6824-6834 (getDocStats)a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/SegmentCommitInfo.java:319-322 (hasDeletions), :370-372 (getSoftDelCount), :453-455 (getDelCount(boolean))a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/ReaderPool.java:152-163 (anyDeletions)a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/PendingDeletes.java:242-246 (getDelCount)a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:542-552 (setSoftDeletesField javadoc)
- Who is affected
a Lucene main checkout lucene/core/src/test/org/apache/lucene/index/TestTryDelete.java:88-160, TestTieredMergePolicy.java:363; lucene/queries/src/test/org/apache/lucene/queries/function/TestFunctionRangeQuery.java:100 (test callers, no soft deletes)
- Why it looks unintended
a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1445-1447 (javadoc), :6454 (numDeletesToMerge fallback)a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:544-547; a Lucene main checkout lucene/CHANGES.txt:7388-7391 (LUCENE-8233)
- Suggested fix
a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/SegmentReader.java:100-105 (assert delCount == 0 without a live-docs file); PendingDeletes.java:171-172 (writeLiveDocs only with pending deletes)
- TestIndexWriterHasDeletionsSoftDeletes.java (one test):
repro/LFV-460/src/org/apache/lucene/index/TestIndexWriterHasDeletionsSoftDeletes.java(50 lines)
assets/LFV-460/bug.png(476 KB)
