forceMergeDeletes asks the merge policy once which merges remove the deletes. LogMergePolicy answers with runs of neighbouring segments that have deletes, and IndexWriter refuses any merge that contains a segment another merge already holds.
- A Lucene index is a list of segments. Deleting a document only marks it deleted; its data stays in the segment until a merge rewrites that segment.
- A merge policy decides which segments to merge.
forceMergeDeletesasks it for merges that remove all deletes;LogMergePolicymerges each run of neighbouring segments that have deletes, up tomergeFactorsegments at a time. - A segment can belong to only one merge at a time. IndexWriter refuses a proposed merge if any of its segments already belongs to a pending or running merge.
- Use
LogDocMergePolicywith merge factor 2 and flush three segments: a (10 docs), b (2 docs) and c (1 doc), with one document of a and one of b deleted. Flushing c registers a natural merge of b and c, which the test's scheduler holds pending. - Call
forceMergeDeletes(true). You expect 0 deleted documents when it returns: b's delete goes away with the natural merge, and a is idle, so a should be merged on its own. - Instead the policy proposes one merge, {a, b}, without checking that b is already merging. IndexWriter rejects it (the only report is an infoStream line, if that is enabled: "reject merge ... is already marked for merge"), runs just the natural merge and returns: a still holds its 1 deleted document.
- No test hook is needed in practice: with the default
ConcurrentMergeScheduler, the call's own flush can start the conflicting merge. A probe with one caller thread (the merge runs on the scheduler's thread) left a 5,000-document segment with all 500 deletes in 50 of 50 runs (19 of 20 with assertions off). - Searches stay correct and a second call cleans up; the cost is disk space and search work. Every release from 2.4.0 to 10.6.0 is affected.
Applications that choose LogDocMergePolicy or LogByteSizeMergePolicy (opt-in since 3.2; the default from 2.4.0 to 3.1) and call forceMergeDeletes while merges run. Deleted documents stay stored in idle segments, costing disk and search work until a later merge; search results, index integrity and the deleted documents' invisibility are unaffected.
Mechanism, terms and numbers726 words
With LogDocMergePolicy or LogByteSizeMergePolicy, IndexWriter.forceMergeDeletes(true) called while another merge holds a segment in the same run of adjacent segments with deletes can return with deleted documents still stored in segments that were never part of any merge. LogMergePolicy.findForcedDeletesMerges groups adjacent segments with deletes into runs without looking at the segments that are already merging; IndexWriter.registerMerge rejects any run that contains such a segment and reports it only through the infoStream, when that is enabled; and forceMergeDeletes asks the policy once and waits only for merges that were registered. In the minimal test the idle 10-document segment keeps its 1 deleted document; in a larger, hook-free scenario a 5,000-document segment keeps all 500 deletes. Deleted documents stay invisible to searches and nothing is corrupted. The default TieredMergePolicy skips merging segments and is not affected by this mechanism. The behaviour dates from the API's creation (2.4.0) and is present in every release up to 10.6.0 and on main.
IndexWriter.forceMergeDeletes(boolean doWait)(IndexWriter.java:2345-2406) first flushes (:2348). A flush that writes a segment or applies deletes callsmaybeMerge(FULL_FLUSH)(:4378-4379), which can register a natural merge; withConcurrentMergeSchedulerit then runs in a background thread.- Under the writer lock it calls
findForcedDeletesMergesonce and passes each proposed merge toregisterMerge, ignoring the boolean result (:2358-2364). LogMergePolicy.findForcedDeletesMerges(LogMergePolicy.java:404-465) walks the segments in order. A segment withnumDeletesToMerge > 0starts or extends a run; a run is emitted atmergeFactorsegments, at the next segment without deletes, or at the end. It never readsmergeContext.getMergingSegments(), so a run can contain a segment that is already pending or running.IndexWriter.registerMerge(IndexWriter.java:5158-5183) finds that segment inmergingSegments, writesreject merge ... is already marked for mergeto the infoStream if theIWcomponent is enabled (:5173-5181) and returnsfalse. The whole merge is dropped, idle segments included.- The wait loop (
IndexWriter.java:2384-2397) waits only while one of the proposed merges is inpendingMergesorrunningMerges. A rejected merge is in neither, so the call returns at once; withConcurrentMergeSchedulerit can return while the conflicting merge is still running. There is no second policy call and no cascade:findForcedDeletesMergesis called only at:2359, and when a merge finishes IndexWriter asks the policy only for natural merges (updatePendingMerges(..., MERGE_FINISHED, ...),:5105, which callsfindMerges,:2508).
LogDocMergePolicy, mergeFactor 2. Segments _0 (10 docs, 1 deleted), _1 (2 docs, 1 deleted) and _2 (1 doc). Flushing _2 registers the natural merge {_1, _2}, held pending by the test's scheduler. forceMergeDeletes(true): the policy logs add merge 0 to 1 inclusive, IndexWriter logs reject merge _0(11.0.0):c10/1 _1(11.0.0):c2/1: segment _1(11.0.0):c2/1 is already marked for merge, the natural merge runs, and the index ends as _0(11.0.0):c10/1 _3(11.0.0):c2: 1 deleted document left instead of 0.
One caller thread, stock ConcurrentMergeScheduler, LogDocMergePolicy(mergeFactor=3): S1 = 5,000 docs with 500 deleted, S2 and S3 = 20 docs with 2 deleted each, plus 20 buffered docs. The call's own flush writes S4 (no deletes) and starts the natural merge S2+S3+S4. The policy proposes one merge, [S1, S2, S3], which IndexWriter rejects because S2 is already marked for merge; after all merges finish the index is _0(11.0.0):c5000/500 _4(11.0.0):c56. It failed 50 of 50 runs with assertions on and 19 of 20 with assertions off on main ea57360912ed, and 10 of 10 on 10.5.2 and on 9.12.3.
TieredMergePolicy.findForcedDeletesMerges drops merging segments before it groups (TieredMergePolicy.java:938, :947, :960), so it is not affected by this mechanism (it still leaves segments below its forceMergeDeletesPctAllowed threshold, 10% by default, on purpose), and so does TemporalMergePolicy (TemporalMergePolicy.java:540-545). LogMergePolicy's own natural-merge path (findMerges) has checked merging segments since LUCENE-2904 (LogMergePolicy.java:497, :604, :638). forceMerge copes with conflicts by taking over pending and running merges and cascading (IndexWriter.java:2260-2278, :5020-5025).
LFV-207 has the same trigger and a different defect: the MergeObserver that forceMergeDeletes returns also tracks the rejected merges, so with doWait=false it never completes. Its fix is in IndexWriter; that fix alone does not make this repro pass.
Exposure and likelihood312 words
- Who is exposed. Applications that configure
LogDocMergePolicyorLogByteSizeMergePolicy(or a subclass) and callforceMergeDeleteswhile a merge holds a segment in the same run of neighbouring segments with deletes. The default merge policy isTieredMergePolicy(LiveIndexWriterConfig.java:143) since 3.2; from 2.4.0 to 3.1LogByteSizeMergePolicywas the default, so the default configuration was affected then (by reading). - Reachable with the stock scheduler once opted in, but timing-dependent. The default scheduler is
ConcurrentMergeScheduler(LiveIndexWriterConfig.java:137), and the conflicting merge can be the one the call's own flush starts, so even waiting for merges before the call does not rule it out. It needs a merging segment with deletes in the same run as an idle segment with deletes; the hook-free rates (50 of 50, 19 of 20) are from one setup.IndexWriter.waitForMerges()is package-private (IndexWriter.java:2878), and no javadoc asks callers to wait first. - What it costs. Deleted documents stay stored in segments that were never merging, for example 500 of 5,000 documents (10% of that segment), using disk space and search work until a second call or some later merge removes them; a later natural merge may pick the segment up, but nothing makes it do so soon.
- Silent. The blocking call returns normally; if the
IWinfoStream is enabled it logs the rejection, otherwise nothing reports it.hasDeletions()still returnstrue. - Not affected. Search results (an executed probe found 0 hits for 506 deleted ids), index integrity (CheckIndex clean), data (nothing lost).
- Workaround. Call
forceMergeDeletes(true)again once the conflicting merge has finished; on an idle writer a second call reclaimed everything. On a LogMergePolicy indexhasDeletions()tells whether deletes remain (withTieredMergePolicydeletes below its threshold stay on purpose, so it is no success test there). WithConcurrentMergeScheduler, its publicsync()waits only for merge threads that are already running (not run as a workaround here).
One JUnit test, 13 documents, single thread. A SerialMergeScheduler subclass holds the natural merge of b and c pending until the test calls forceMergeDeletes(true), which makes it deterministic. Run it with a fixed seed; it fails the same way with assertions on and off.
/*
* 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 java.util.List;
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;
public class TestLogMergePolicyForceMergeDeletesWhileMerging extends LuceneTestCase {
private volatile boolean runMerges;
public void testForceMergeDeletesWhileNaturalMergeIsPending() throws Exception {
LogDocMergePolicy mp = new LogDocMergePolicy();
mp.setMergeFactor(2);
MergeScheduler heldUntilRunMerges =
new SerialMergeScheduler() {
@Override
public synchronized void merge(MergeSource mergeSource, MergeTrigger trigger)
throws IOException {
if (runMerges) {
super.merge(mergeSource, trigger);
}
}
};
IndexWriterConfig iwc =
new IndexWriterConfig().setMergePolicy(mp).setMergeScheduler(heldUntilRunMerges);
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
addSegment(w, "a", 10);
addSegment(w, "b", 2);
w.deleteDocuments(new Term("id", "a0"), new Term("id", "b0"));
addSegment(w, "c", 1);
runMerges = true;
// expected: a0 is merged away; actual: segment a is rejected with the already-merging b
w.forceMergeDeletes(true);
IndexWriter.DocStats stats = w.getDocStats();
assertEquals("deleted docs after forceMergeDeletes(true)", 0, stats.maxDoc - stats.numDocs);
}
}
private static void addSegment(IndexWriter w, String prefix, int numDocs) throws IOException {
for (int i = 0; i < numDocs; i++) {
w.addDocument(List.of(new StringField("id", prefix + i, Field.Store.NO)));
}
w.flush();
}
}
cp TestLogMergePolicyForceMergeDeletesWhileMerging.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE
./gradlew :lucene:core:test --tests TestLogMergePolicyForceMergeDeletesWhileMerging -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false
- Expected
- 0 deleted documents when
forceMergeDeletes(true)returns. - Observed
- 1 deleted document left in segment a, which was never merging:
expected:<0> but was:<1>.
More files, environment, full output216 words
Every API the test uses is public; it sits in org.apache.lucene.index only to live next to the other merge-policy tests. LuceneTestCase randomizes the codec, so the command fixes the seed; a second seed (C0FFEE) fails the same way. Segments are written with flush(), not commit(): with commit() the default MergePolicy.findFullFlushMerges would register the {b, c} merge as a merge-on-commit and the commit would wait for it.
The same test is the suggested regression test: it fails today and passes with the recommended fix. Lucene's build checks (google-java-format, ECJ, forbidden APIs) pass with it in place.
| lucene | main 645d4fe14a20 (2026-10-28); also releases 10.6.0 and 10.5.2 |
|---|---|
| jdk | OpenJDK 25.0.3 (25.0.3+9-LTS) |
| os | Linux, aarch64 |
# main 645d4fe14a20, gradle, -Ptests.seed=DEADBEEF -Ptests.asserts=true (and false)
TestLogMergePolicyForceMergeDeletesWhileMerging > testForceMergeDeletesWhileNaturalMergeIsPending FAILED
java.lang.AssertionError: deleted docs after forceMergeDeletes(true) expected:<0> but was:<1>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:B4BBB28F682B14EB]:0)
# infoStream of the same scenario
IW: add merge to pendingMerges: _1(11.0.0):c2/1 _2(11.0.0):c1 [total 1 pending]
IW: forceMergeDeletes: index now _0(11.0.0):c10/1 _1(11.0.0):c2/1 _2(11.0.0):c1
MP: add merge 0 to 1 inclusive
IW: reject merge _0(11.0.0):c10/1 _1(11.0.0):c2/1: segment _1(11.0.0):c2/1 is already marked for merge
IW: merge seg=_3 _1(11.0.0):c2/1 _2(11.0.0):c1
(index at close: _0(11.0.0):c10/1 _3(11.0.0):c2)
# 10.6.0 and 10.5.2, assertions on and off: the same AssertionError
# with the recommended fix: BUILD SUCCESSFUL, the test passes in both modes- LogMergePolicy's javadoc promises the merges "necessary to force-merge all deletes from the index", and upstream tests assert none remain.
- TieredMergePolicy, TemporalMergePolicy and LogMergePolicy's own natural merges all skip segments that are already merging.
forceMergeDeletesasks the policy once and never retries, unlikeforceMerge, and its own flush can start the conflicting merge.
The full argument275 words
Contract.
LogMergePolicy.java:404-407: "Finds merges necessary to force-merge all deletes from the index. We simply merge adjacent segments that have deletes, up to mergeFactor at a time."MergePolicy.java:654-656: "Determine what set of merge operations is necessary in order to expunge all deletes from the index."IndexWriter.java:2408-2411: "Forces merging of all segments that have deleted documents. The actual merges to be executed are determined by the MergePolicy. For example, the default TieredMergePolicy will only pick a segment if the percentage of deleted docs is over 10%." The only exception it names is TieredMergePolicy's threshold.lucene/CHANGES.txt:19981-19984(2.4.0, LUCENE-325): "Added IndexWriter.expungeDeletes methods, which consult the MergePolicy to find merges necessary to merge away all deletes from the index."
Tests. TestIndexWriterMerging.testForceMergeDeletes, testForceMergeDeletes2 and testForceMergeDeletes3 (with newLogMergePolicy, maxDoc == numDocs afterwards) and TestIndexWriterReader.testForceMergeDeletes (assertFalse(r.hasDeletions())) assert that no deletes remain with LogMergePolicy, on an idle writer. No upstream test checks the result of forceMergeDeletes on LogMergePolicy while a merge is pending or running (RandomIndexWriter may call forceMergeDeletes() at random, with a randomized policy and scheduler, but asserts nothing about deletes).
Intent. IndexWriter.getMergingSegments is documented as "to be used by a MergePolicy to avoid selecting merges for segments already being merged" (IndexWriter.java:2520-2523). LUCENE-2904 (#3978, 3.2) added that check to LogMergePolicy's natural merges only. In LUCENE-3569 (#4643), Michael McCandless wrote in 2011 that a forced-deletes selection "would have to check which merges are already running/registered (IW.getMergingSegments) and "work around" them. EG, if there are 7 segments with deletions, you check and see that 4 of them are already merging / scheduled for merge, so you know you only have to merge the other 3."
The devil's-advocate check204 words
The strongest case for "intended": IndexWriter rejects conflicting merges by design (in the LUCENE-847 merge-policy design discussion, 2007, Michael McCandless agreed to keep the check for merges already in progress in IndexWriter: "OK, let's leave eligibility check in IW"), and forceMergeDeletes hands the choice to the policy, so leaving deletes behind while other merges run is best effort; callers who need every delete gone should wait for merges first. Maintainers may also prefer an IndexWriter-side retry or a javadoc note to a policy change.
Why it fails: the policy did choose segment a, and IndexWriter then discarded that choice; LogMergePolicy has no threshold that would excuse a. Rejection is harmless for natural merges because the policy is asked again at the next flush or merge, but forceMergeDeletes asks once and has no cascade, unlike forceMerge. No javadoc says to wait for merges first, IndexWriter.waitForMerges() is package-private, and the call's own flush can start the conflicting merge after any such wait. The in-core policies that choose forced-deletes merges themselves (TieredMergePolicy, TemporalMergePolicy) skip merging segments; the other core implementations return null or delegate. No javadoc, CHANGES entry, test, issue, PR or list thread calls this best effort. Verdict: a defect, not intended.
Recommended LogMergePolicy: a segment that is already merging ends the current run
Make LogMergePolicy.findForcedDeletesMerges treat a segment that is already merging as the end of a run (one condition). Nothing changes when no merge is in flight; the repro passes, and so do 272 core merge and IndexWriter tests. Alternative: IndexWriter retries rejected forced-deletes merges.
assert mergeContext != null;
+ final Set<SegmentCommitInfo> mergingSegments = mergeContext.getMergingSegments();
for (int i = 0; i < numSegments; i++) {
final SegmentCommitInfo info = segmentInfos.info(i);
int delCount = mergeContext.numDeletesToMerge(info);
assert assertDelCount(delCount, info);
- if (delCount > 0) {
+ // a segment that is already being merged ends the current run: IndexWriter would reject a
+ // merge that includes it (and its deletes are reclaimed by the running merge anyway)
+ if (delCount > 0 && mergingSegments.contains(info) == false) {All options and trade-offs3 options · 587 words
The LogMergePolicy change was built and checked in a scratch worktree of 645d4fe14a20; the IndexWriter retry was not built.
Workaround for users today: call forceMergeDeletes(true) again once the conflicting merge has finished (on an idle writer a second call reclaimed everything); on a LogMergePolicy index hasDeletions() tells whether deletes remain. IndexWriter.waitForMerges() is not public; with ConcurrentMergeScheduler, its public sync() waits for running merge threads (not run as a workaround here).
LogMergePolicy: a segment that is already merging ends the current run (validated by the checks) (recommended)
In findForcedDeletesMerges (+4/-1), read mergeContext.getMergingSegments() once and treat a merging segment like a segment without deletes. The run before it is then emitted through the existing "end of a sequence" branch (LogMergePolicy.java:441-451), and a new run can start after it. In the minimal test the policy then proposes {a} alone.
assert mergeContext != null;
+ final Set<SegmentCommitInfo> mergingSegments = mergeContext.getMergingSegments();
for (int i = 0; i < numSegments; i++) {
final SegmentCommitInfo info = segmentInfos.info(i);
int delCount = mergeContext.numDeletesToMerge(info);
assert assertDelCount(delCount, info);
- if (delCount > 0) {
+ // a segment that is already being merged ends the current run: IndexWriter would reject a
+ // merge that includes it (and its deletes are reclaimed by the running merge anyway)
+ if (delCount > 0 && mergingSegments.contains(info) == false) {Trade-offs
- No change when nothing is merging (the set is empty), so idle-writer behaviour and the existing tests are unchanged.
- Runs can be split into smaller merges (even one segment) while a merge is in flight.
- The merging segment's deletes are left to the merge that holds it; deletes applied to it after that merge started are carried into the merged segment, as before (older behaviour, not claimed here).
getMergingSegments()is read under the writer lock, whichforceMergeDeletesholds around both the policy call andregisterMerge(IndexWriter.java:2358-2364), so the set cannot change in between.- Fixes LogMergePolicy only: a custom policy that makes the same mistake still meets the IndexWriter half.
What the checks validated
- The minimal test fails without the fix and passes with it, through gradle, assertions on and off.
- With the fix:
TestLogMergePolicy,TestIndexWriterMerging,TestIndexWriterMergePolicyand the new test, 48 tests (4 skipped: 2 nightly, 2@AwaitsFixfor issue #14483), 0 failures in both modes; every coreorg.apache.lucene.index.*Merg*suite plusTestIndexWriterReaderandTestIndexWriterDelete, 52 suites, 272 tests (15 skipped), 0 failures;checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestpass. - Earlier, by the hunt: the independent reproducer's 5 tests and the hook-free probe (50 of 50) pass with the same change; the bounded-exhaustive check found 20,384 violations in 100,800 checks per LogMergePolicy without the change and 0 violations with it (109,952 checks for LogByteSizeMergePolicy in that run); the change applied to 3.6.2 passes the old-line port.
When registerMerge returns false for a merge proposed by findForcedDeletesMerges, wait for the conflicting merges and ask the policy again, or carry the request over the way forceMerge does (IndexWriter.java:2260-2278, :5020-5025). Not built.
Trade-offs
Also covers custom policies, but changes IndexWriter for every policy, can make the blocking call wait for long unrelated natural merges, and needs a guard against re-asking forever when a policy keeps proposing the same conflicting merge. It touches the same lines as the LFV-207 fix (keep only registered merges in the returned MergeObserver).
The LogMergePolicy change for its own correctness, the IndexWriter retry for robustness against any policy.
Trade-offs
Most code and most review.
If an application uses forceMergeDeletes on a LogMergePolicy index as a step to physically remove deleted data, it should check hasDeletions() afterwards and repeat the call; deleted documents stay invisible to searches either way.
A bounded-exhaustive check ran the real merge-policy classes over every small index layout against simple rules. "After forceMergeDeletes no idle segment has deletes" failed 20,384 of 100,800 checks for each LogMergePolicy and none of 148,331 for TieredMergePolicy; a JUnit test on the stock build confirmed it.
The method in full152 words
Method: semi-formal (bounded-exhaustive translation validation of the real code).
- Check. A test program drove the shipped
LogByteSizeMergePolicy,LogDocMergePolicyandTieredMergePolicyclasses over every index of 2 to 4 segments from a small grid of sizes and delete fractions, with every non-empty proper subset of the segments marked as already merging, and checked rules such as "after forceMergeDeletes every segment that is not merging is free of deletes". - Result. That rule failed 20,384 of 100,800 checks for each LogMergePolicy and 0 of 148,331 for TieredMergePolicy; a companion rule (no proposed merge may contain a segment that is already merging, which
registerMergewould reject) failed 49,408 times per LogMergePolicy. With the one-line fix: 0 violations. - Confirmation. A JUnit test on the stock jars (a gated scheduler holding the natural merge pending, and a latch holding it running in
ConcurrentMergeScheduler), then independent verifiers (section "Prior reports, history and checks"), then this minimal test.
No prior report found (2026-10-26: 142 queries over GitHub, the JIRA archive, mailing lists, downstream trackers and the web, plus the files of all 413 open PRs). LUCENE-2904 added the same check to LogMergePolicy's natural merges only.
Searches2 searches
Verdict new. Closest items, none the same bug: LUCENE-2904 (#3978, fixed in 3.2), the merging-segments check for findMerges only (precedent); LUCENE-3569 (#4643, open design discussion), a committer comment that a forced-deletes selection must work around merging segments (intent); LUCENE-8253 (#9300), forceMergeDeletes left soft deletes, a different cause fixed in 7.4; #16701 / #16742, LogMergePolicy natural merges in levels that never fill, not this method; #16847, the only open PR touching LogMergePolicy.java, leaves findForcedDeletesMerges unchanged. No commit on upstream main up to 2f424fd428d (2026-10-30) touches LogMergePolicy.java or IndexWriter.java after 645d4fe14a20. Limits: keyword-based searches; private security reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr), dev@/java-user@/issues@/commits@ lists, JIRA REST, the web | 2026-10-26 | scripted prior-report search runs, 69 GitHub GraphQL searches (incl. the infoStream strings "reject merge" and "already marked for merge"), JIRA JQL; 142 queries, 21 hits read; 28 JIRA issues (27 LUCENE, 1 SOLR) and 5 list threads read in full; touched files of all 413 open PRs | no report; related: LUCENE-2904, LUCENE-3569, LUCENE-8253, #16701/#16742, #16847 |
| upstream main history (LogMergePolicy.java, IndexWriter.java) | 2026-10-31 | git log 645d4fe14a20..2f424fd428d on both files | no commit |
As old as the API: expungeDeletes (LUCENE-325, 2008) brought in both halves, first released in 2.4.0. Fails on 13 releases from 2.4.0 to 10.6.0 and on main; never fixed.
Versions tested6 rows
Introduced by 1d4ab6879680 (svn r620604, 2008-02-11), "LUCENE-325: add expungeDeletes methods to IndexWriter" (now GitHub #1403; no PR). It added IndexWriter.expungeDeletes and LogMergePolicy.findMergesToExpungeDeletes together: a selection that never consults merging segments, and one policy call whose rejected merges are not retried. 2.3.2 has no such API. LUCENE-3577 (3.5.0) renamed the methods to forceMergeDeletes / findForcedDeletesMerges; none of the 19 later commits that touched the method (20 with the introducing one) added a merging-segments check, and every release tag from 2.4.0 on contains it unchanged in this respect.
Old releases cannot compile today's test (test-framework moves, scheduler API, the rename), so they ran version ports of the hunt's scenario with the same oracle; a no-concurrent-merge control passed on every old version where it ran, and the one-line fix applied to 3.6.2 made the port pass.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20; 10.6.0; 10.5.2 | minimal test | 1 deleted document left (expected:<0> but was:<1>), assertions on and off |
main ea57360912ed, a9779ef3c193; 10.5.2; 9.12.3; 8.11.4 | hunt scenario | _0 c5000/500 _4 c54: the 5,000-doc segment keeps all 500 deletes |
main ea57360912ed; 10.5.2; 9.12.3 | hook-free probe | 50/50 (-ea) and 19/20 (-da) on main; 10/10 on each release |
| 2.4.0, 2.4.1, 2.9.4, 3.0.3, 3.6.2, 4.10.4, 5.5.5, 6.6.6, 7.7.3 | version ports | the port fails; the no-concurrent-merge control passes where it ran (every version but 2.4.1) |
| 3.6.2 with the fix | fix shadow | the port passes |
| 2.3.2 | before the API | no expungeDeletes method (the port does not compile) |
Reproduced by the finder, an independent public-API reproducer, a devil's-advocate verifier with a hook-free probe, the adjudication, the repro-minimization step and this doc; a reviewer from another model family (OpenAI GPT-5.6) found it real on the works-as-designed lens and could not execute on the refute lens.
Checks and verdicts308 words
Every reproduction ran on stock builds; fix checks ran with the patched LogMergePolicy. The cross-model reviews only read code; a separate re-run of the repro failed as claimed.
| Check | Verdict | What it found |
|---|---|---|
| Finder (bounded-exhaustive check, JUnit) | candidate | 20,384 of 100,800 rule checks fail per LogMergePolicy, 0 for TieredMergePolicy, 0 with the fix; JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3. |
| Cross-model review, refute lens (OpenAI GPT-5.6, reading only) | not real (low, procedural) | Could not execute; no substantive refutation, agreed on the root cause. Its point that a gated scheduler makes a transient race permanent is answered by the stock-scheduler test and the hook-free probe. |
| Independent reproducer (public API only) | real | On main (-ea, -da) 3 of 5 tests fail and both controls pass (TieredMergePolicy under the same merge, LogDocMergePolicy with no concurrent merge); on 10.5.2 and 9.12.3, 3 of the 5 ran (the running- and pending-merge tests fail, the TieredMergePolicy control passes); all 5 pass with the fix. |
| Works-as-designed lens (OpenAI GPT-5.6, reading only) | real (medium) | No documented concurrency exception found. |
| Intended-behaviour devil's advocate | real | Hook-free probe: 50/50 fail with -ea, 19/20 with -da on main, 10/10 on 10.5.2 and 9.12.3; 50/50 pass with the fix. |
| Adjudication | confirmed (high) | Re-ran the independent reproduction with a new seed (3 of 5 fail in both modes; 5 of 5 pass with the fix) and the probe (20/20 fail in both modes). |
| Repro minimization | verified | The one-test repro fails on main 645d4fe14a20 with -ea, -da and a second seed, and passes with the fix. |
| This doc (2026-10-31) | confirmed | Fails on main, 10.6.0 and 10.5.2 in both modes and through gradle; with the fix the test, 272 core merge and IndexWriter tests and the lint checks pass. |
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 520 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,257 words
- Registry title
- LogMergePolicy.findForcedDeletesMerges does not skip segments that are already merging, so IndexWriter rejects the whole run and forceMergeDeletes(true) returns with deletes left in segments that were never merging
- Area
- index / merge policy (LogMergePolicy forced-deletes selection, IndexWriter.forceMergeDeletes)
- Priority
- Low, rank 1006: LogMergePolicy forced-deletes runs that include an already merging segment are rejected, so forceMergeDeletes leaves deletes behind, opt-in policy and disk space only.
- Impact class
- minor
- Impact note
- Applications that choose LogDocMergePolicy or LogByteSizeMergePolicy (opt-in since 3.2; the default TieredMergePolicy is not affected) and call forceMergeDeletes while a merge holds a segment in the same run of adjacent segments with deletes, which with the default ConcurrentMergeScheduler can be the natural merge started by the call's own flush, are left with already-deleted documents still stored in segments that were never merging, at any scale and in every release since 2.4.0; the cost is disk space and search work until a later merge or a second call, the deleted documents stay invisible to searches, and nothing is lost or corrupted.
- Summary
- You use LogDocMergePolicy or LogByteSizeMergePolicy and call IndexWriter.forceMergeDeletes(true) while another merge is pending or running, for example the natural merge that the call's own flush starts. You expect what LogMergePolicy's javadoc says ('Finds merges necessary to force-merge all deletes from the index'): every segment with deletes that is not already being merged is merged before the blocking call returns. Instead the policy groups adjacent segments with deletes into runs without skipping the ones that are already merging, IndexWriter rejects every run that contains such a segment (it only writes an infoStream message), and the call returns with the other segments of that run still holding their deletes, for example a 5000-document segment that keeps all 500 of its deletes.
- Introduced
- 1d4ab6879680d35a212539a85ff942e081ef0579 (svn r620604, 'LUCENE-325: add expungeDeletes methods to IndexWriter', now apache/lucene#1403; committed from a JIRA patch, no PR) [read: git log -S / -L and blame in the full-history clone; the commit added IndexWriter.expungeDeletes and LogMergePolicy.findMergesToExpungeDeletes with both halves of the bug at once: a selection that never consults the merging segments, and a single policy call that ignores registerMerge's false result and waits only for the merges it proposed] [exec: 2.3.2, the release before, has no such API (the port fails to compile on the missing method), 2.4.0 fails, and the one-line fix shadowed onto 3.6.2 passes]; LUCENE-3577 (5267d23d4661, 3.5.0) renamed the methods to forceMergeDeletes / findForcedDeletesMerges, and none of the 19 later commits that touched the method (20 with the introducing one) added a merging-segments check (2008-02-11)
- First affected version
- 2.4.0 [exec] (CHANGES.txt 2.4.0, API Changes item 4, LUCENE-325); 2.3.2 and earlier have no expungeDeletes API [exec: compile error]
- Affected versions
- every release from 2.4.0 through 10.6.0, and main ea57360912ed. [exec] the canonical scenario (S1 5000 docs with 500 deleted, S2-S4 20 docs with 2 deleted each, LogDocMergePolicy mergeFactor=3, a natural merge of S2-S4 pending or running) fails on 2.4.0 (pending variant -ea and -da, running variant -ea), 2.4.1, 2.9.4, 3.0.3, 3.6.2, 4.10.4, 5.5.5, 6.6.6 and 7.7.3 (version ports of the pending variant; the same program without a concurrent merge passes on 2.4.0, 2.9.4, 3.0.3, 3.6.2, 4.10.4, 5.5.5, 6.6.6 and 7.7.3), 8.11.4 (the canonical JUnit with two test-framework imports changed), 9.12.3 and 10.5.2 (-ea and -da) and main (-ea and -da); the intended-behaviour check's hook-free probe fails on main (50/50 -ea, 19/20 -da), 10.5.2 (10/10) and 9.12.3 (10/10). [read] all 138 release tags from 2.4.0 on contain the introducing commit, and the method never mentions merging segments at any of them; the default configuration was affected in 2.4.0-3.1, when LogByteSizeMergePolicy was the default, and since 3.2 only applications that choose a LogMergePolicy are. 10.6.0 (released 2026-10-29) is affected [exec 2026-10-31: the minimal test fails on the 10.6.0 release jars (SHA-1 checked against Maven Central) with -ea and -da]; the method is unchanged on main. All runs on aarch64 with JDK 25.
- 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
- semi-formal: exhaustive check
- Upstream and prior reports
- none found (2026-10-26, read-only: a scripted prior-report search over apache/lucene issues, PRs and commits, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 69 GitHub GraphQL searches over apache/lucene and Elasticsearch, OpenSearch and Solr; JIRA REST for LUCENE and SOLR; 28 JIRA issues (27 LUCENE, 1 SOLR) and 5 mailing-list threads read in full; the touched files of all 413 open apache/lucene PRs; 142 queries, 21 hits read, none the same bug). Closest: LUCENE-2904 (#3978, fixed in 3.2) added the merging-segments check to LogMergePolicy's natural-merge path (findMerges) only; LUCENE-3569 (#4643, open design discussion since 2011) has a committer saying a forced-deletes selection would have to work around already-merging segments; the only open PR that touches LogMergePolicy.java (#16847) leaves findForcedDeletesMerges unchanged.
- Lucene commit tested
- main
645d4fe14a20(2026-10-31): the minimal test fails with the standalone JUnit runner and through gradle in a scratch worktree, with assertions on and off; with the recommended fix it passes, the closest suites (48 tests) pass in both modes, a wider run of every coreorg.apache.lucene.index.*Merg*suite plusTestIndexWriterReaderandTestIndexWriterDelete(52 suites, 272 tests) passes, and core's format, ECJ and forbidden-apis checks pass. Releases 10.6.0 and 10.5.2 fail the minimal test both ways. The hunt's larger scenario failed on mainea57360912edanda9779ef3c193, on 12 releases from 2.4.0 to 10.5.2 (old ones through version ports), and its hook-free probe on main, 10.5.2 and 9.12.3.
- Finding record
lucene/core/src/java/org/apache/lucene/index/LogMergePolicy.java:404-465 (javadoc 'Finds merges necessary to force-merge all deletes from the index'; findForcedDeletesMerges never consults getMergingSegments), :497, :604, :638 (findMerges does check mergingSegments)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2345-2405 (forceMergeDeletes: one policy call, registerMerge result ignored, waits only on registered merges), :2409-2411 (javadoc), :5157-5183 (registerMerge rejects a merge with an already-merging segment, infoStream only)lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:938-963 (forced-deletes selection skips merging segments)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2345-2406, 2508, 4378-4379, 5105, 5158-5183a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/LogMergePolicy.java:404-465, 497, 604, 638a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:938-963a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/TemporalMergePolicy.java:528-546a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:81 (forceMergeDeletesPctAllowed = 10.0)
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:137, 143a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1449, 2878a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:477-481a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5173-5181 (reject message only when infoStream.isEnabled("IW"))
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/LogMergePolicy.java:404-407a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:654-656a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2408-2411, 2520-2523, 2878a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:19981-19984a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestIndexWriterMerging.java:120-308; TestIndexWriterReader.java:936-953a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/index/RandomIndexWriter.java:458-484, 491-492, 535-538 (random forceMergeDeletes, no assertion on deletes)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/NoMergePolicy.java, FilterMergePolicy.java, OneMergeWrappingMergePolicy.java, TemporalMergePolicy.java (findForcedDeletesMerges)
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2260-2278, 2358-2359, 2878, 5020-5025a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/LogMergePolicy.java:441-451
- Prior reports
a Lucene clone: git log 645d4fe14a20..origin/main (2f424fd428d) -- LogMergePolicy.java IndexWriter.java (empty)
- Diagrams
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/LogMergePolicy.java:404-465; IndexWriter.java:5158-5183
- TestLogMergePolicyForceMergeDeletesWhileMerging.java (lucene/core/src/test/org/apache/lucene/index/):
repro/LFV-178/src/org/apache/lucene/index/TestLogMergePolicyForceMergeDeletesWhileMerging.java(66 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed minimal test.
assets/LFV-178/bug.png(520 KB)
