All findings · Lucene finding · LFV-213 · temporal-mp-forced-deletes-skips-lone-segment

TemporalMergePolicy: forceMergeDeletes() skips a segment that is the only one over the delete threshold in its time window

With TemporalMergePolicy, forceMergeDeletes() skips any segment that is the only one over the 10% deleted threshold in its time window: one segment of 2 documents, 1 deleted, keeps maxDoc 2 on every call. On a layout of one segment per window, which is what forceMerge(1) leaves, it merges nothing at all.

Summary

Priority
Low, rank 1009TemporalMergePolicy never expunges deletes from a window's lone eligible segment, an experimental 10.5 policy and disk space only.
Impact
minor
Since
10.5.0 (#15620, 2026); original codeintroduced 2026-03-17
Affected
10.5.0-10.5.2, 10.6.0 and main
Status
documented
Found by
non-formal: code reading, then an IndexWriter-level JUnit

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Background

  1. A deleted document stays in its segment, marked as deleted, until a merge rewrites that segment. IndexWriter.forceMergeDeletes() asks the merge policy which segments to rewrite; by default a policy picks those with more than 10% deleted documents.
  2. TemporalMergePolicy (opt-in, new in 10.5.0) puts each segment into a time window by its newest timestamp and never merges segments of different windows, so forceMerge(1) leaves one segment per window.
Diagram of the bug: with TemporalMergePolicy, a one-segment index of 2 documents has 1 document deleted, 50 percent, above the default 10 percent threshold. forceMergeDeletes() should rewrite the segment so that maxDoc becomes 1. The policy groups the eligible segments by time window and skips every window with fewer than two of them, so it returns no merge and maxDoc stays 2. Searches still do not return the deleted document.
One time window holds one segment of 2 documents, 1 deleted (50%, above the 10% threshold). forceMergeDeletes() should rewrite it to maxDoc 1, but the policy skips every window with fewer than two eligible segments, so no merge runs and maxDoc stays 2. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index with new TemporalMergePolicy().setTemporalField("ts") and default settings: 2 documents with a LongPoint timestamp, then commit(). The index has one segment, alone in its time window.
  2. Delete 1 of the 2 documents (50%, above the default forceMergeDeletesPctAllowed of 10%) and call forceMergeDeletes(). You expect the segment to be rewritten without it: maxDoc 1, as LogMergePolicy does in the same test.
  3. Instead no merge is scheduled and maxDoc stays 2. Three calls in a row run 0 merges each, and forceMerge(1) does not rewrite the segment either.
  4. Why: the policy groups the eligible segments by time window and skips every window with fewer than two of them; it also drops the one left over after full batches of 8. So of 9 eligible segments in one window one keeps its deletes, and on a layout of one segment per window (what forceMerge(1) leaves) forceMergeDeletes() merges nothing.

Who is affected

Applications that chose TemporalMergePolicy (opt-in, experimental, since 10.5.0) and call forceMergeDeletes(), with default settings. The forced-deletes step skips every window with exactly one segment over the threshold, on every call, as in a single-segment index or after forceMerge(1). Searches stay correct; the deleted documents keep costing disk space and search work.

Mechanism, terms and numbers724 words

TemporalMergePolicy (opt-in, @lucene.experimental, first released in 10.5.0) groups segments into time windows and never merges across windows. For IndexWriter.forceMergeDeletes() its javadoc says it merges segments with more than forceMergeDeletesPctAllowed (default 10%) deleted documents. But findForcedDeletesMerges skips every window with fewer than two such segments, and its batching helper drops the one left over after full batches of maxThreshold (default 8). So a segment that is the only eligible one in its window keeps its deleted documents: a one-segment index with 1 of 2 documents deleted keeps maxDoc 2, repeated calls schedule nothing, and forceMerge(1), which has the same guard, does not rewrite it either. Searches stay correct; the cost is disk space and search work. Affected: 10.5.0, 10.5.1, 10.5.2, 10.6.0 and main.

Terms

  • Time window: TemporalMergePolicy assigns each segment to a window by the largest value of its LongPoint timestamp field. Recent windows are small (1 hour by default) and older ones grow by a factor of minThreshold (4) up to 365 days; disableExponentialBuckets() makes them all the same size (baseTimeSeconds, without the 365-day cap). Segments of different windows are never merged, "even during forceMerge(int)" (class javadoc, TemporalMergePolicy.java:87-89).
  • Eligible segment: for forceMergeDeletes(), a segment that is not already merging and whose share of deleted documents is above forceMergeDeletesPctAllowed (default 10.0, :111, javadoc :353-359).

The bug

  1. IndexWriter.forceMergeDeletes() first flushes and applies the deletes (IndexWriter.java:2348). That flush also runs a natural merge pass (findMerges, :4378-4379, :2508), which for this policy needs 4 segments in a window and finds nothing here. Then it calls findForcedDeletesMerges once (:2359). If the policy returns nothing, no forced merge runs and the call returns without waiting (:2369, :2405).
  2. TemporalMergePolicy.findForcedDeletesMerges (:527-598) computes pctDeletes = (100.0d * delCount) / maxDoc (:549-554), keeps the segments above the threshold (:555) and groups them by window (:568).
  3. Per window: if (bucketSegments.size() < 2) { continue; } (:573-575). A window with one eligible segment is dropped, whatever else it holds.
  4. Windows with two or more go to buildSequentialMerges (:951-978). It returns null for fewer than two candidates (:952-954), emits batches of exactly maxThreshold (default 8, :108), and emits the last batch only if (batch.size() > 1) (:970). So 9 eligible segments in a window leave the 9th with its deletes.
  5. Natural merges never look at deletes and need at least minThreshold (4) segments in a window (:833), so the deletes stay until enough new segments land in that window. With the default growing windows an aging segment can later share a bigger window and be merged then. That may never happen with fixed windows (all baseTimeSeconds wide, :796-797), for data old enough to sit in the capped 365-day windows (:803-809), or in a single window or a single-segment index.
  6. The forceMerge twin. findForcedMerges (:507-509) and buildForcedMerges (:925-927) have the same < 2 guard, so forceMerge(1) does not rewrite a lone segment with deletes either. forceMerge still keeps its promise (at most N segments, never across windows); only the deletes stay.

The numbers

Default settings, one segment of 2 documents, 1 deleted (50%): forceMergeDeletes() schedules 0 merges and maxDoc stays 2; three calls in a row, then forceMerge(1): still maxDoc 2, hasDeletions() true. The verifiers' wider layouts on the same commit: two windows with one 50%-deleted segment each keep both ([10/5][10/5]); one eligible segment among three in a window keeps its deletes ([10/0][10/5][10/0]); 9 eligible segments in one window leave _6 c10/5 beside the merged _9 c40; forceMerge(1) on one segment with 5 of 10 deleted leaves _0 c10/5. Two eligible segments in one window are merged correctly. LogMergePolicy rewrites the minimal repro's segment in the same test (1 merge, maxDoc 1). TieredMergePolicy is not a clean control for that exact test: the flush inside forceMergeDeletes() first starts a natural merge of the segment, so the call reports 0 merges and returns with maxDoc 2 (1 once the background merge finishes). Its forced-deletes path does return the one-segment merge when it alone decides, and the verifiers' TieredMergePolicy controls (their own layouts, a serial merge scheduler, the writer closed before checking) expunge every layout.

Exposure and likelihood290 words
  • Who is hit. Users of TemporalMergePolicy (@lucene.experimental, TemporalMergePolicy.java:94; first released in 10.5.0) who call forceMergeDeletes() to reclaim deletes. Default settings and the public API are enough, and the outcome is deterministic.
  • Typical trigger states. A single-segment index; any index after forceMerge(1) (one segment per window); an old window whose segments were already merged into one; the odd segment after full batches of 8.
  • What it costs. Hits stay correct. The deleted documents stay in the segment files, still count in maxDoc, docFreq and other term statistics, keep their terms in the terms dictionary (a spell checker still suggested a term found only in a deleted document), and query paths that visit them must skip them. They never come back as hits (checked before and after the call). Nothing is corrupted or lost.
  • No fix from this policy's own calls. Repeating the call schedules nothing again, and forceMerge(1) skips the segment too. Natural merges ignore deletes. A workaround, partly run: on a one-segment layout with a serial merge scheduler, switching the live config to a TieredMergePolicy with setForceMergeDeletesPctAllowed(0) and calling forceMergeDeletes(true) removed the deletes. Then restore the TemporalMergePolicy and commit (not run). With the default concurrent scheduler check hasDeletions() only after the merges finish; with several windows this can mix windows (by reading).
  • Scale. Any index size; the cost is the deleted documents of each skipped segment. Fully deleted segments are dropped by IndexWriter anyway.
  • Tested with hard deletes only. Every run used deleteDocuments; soft deletes were not tested.
  • How likely. Plausible: the trigger states are normal for this policy, but it is new and experimental, no downstream user is known, and forceMergeDeletes is an explicit call that IndexWriter's javadoc says "rarely is it warranted".

Minimal repro

One JUnit test in lucene/core, public API, a default TemporalMergePolicy with only the timestamp field set: a one-segment index of 2 documents, 1 deleted, then forceMergeDeletes(). Deterministic; it fails the same way with assertions on and off on main, and on the 10.6.0 release (run with assertions on).

TestTemporalMergePolicyForceMergeDeletes.java46 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.index;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.LongPoint;
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 TestTemporalMergePolicyForceMergeDeletes extends LuceneTestCase {

  public void testForceMergeDeletesSingleSegment() throws Exception {
    TemporalMergePolicy policy = new TemporalMergePolicy().setTemporalField("ts");
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setMergePolicy(policy))) {
      for (String id : new String[] {"a", "b"}) {
        Document doc = new Document();
        doc.add(new StringField("id", id, Field.Store.NO));
        doc.add(new LongPoint("ts", 1_700_000_000L));
        w.addDocument(doc);
      }
      w.commit();
      w.deleteDocuments(new Term("id", "a"));
      w.forceMergeDeletes();
      // expected: the lone 50%-deleted segment is rewritten (maxDoc 1); actual: no merge (maxDoc 2)
      assertEquals("maxDoc after forceMergeDeletes", 1, w.getDocStats().maxDoc);
    }
  }
}
Command
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestTemporalMergePolicyForceMergeDeletes -Ptests.seed=DEADBEEF
Expected
forceMergeDeletes() rewrites the 50%-deleted segment on its own: maxDoc 1.
Observed
No merge is scheduled: maxDoc after forceMergeDeletes expected:<1> but was:<2>.

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

More files, environment, full output271 words

Package org.apache.lucene.index, like the policy's own tests; one method; default IndexWriterConfig with only the merge policy set; ByteBuffersDirectory. The timestamp is a fixed value in seconds; any value gives the same result because the segment is alone in its window. Nothing else is randomized that matters: two seeds give the same failure with assertions on and off.

With the recommended fix the same file is the regression test: it passes, and :lucene:core:check -x test (google-java-format, ecj lint, forbidden APIs) passes with it in the tree. The wider layouts (one eligible among several, one per window, the leftover after a full batch, the forceMerge(1) twin) are covered by the verifiers' runs, not by more tests.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 release jars
jdkOpenJDK 25.0.3
osLinux, aarch64
Command (environment)
cd $LUCENE   # apache/lucene at 645d4fe14a20 (or any commit with the same TemporalMergePolicy.java)
cp $REPRO/TestTemporalMergePolicyForceMergeDeletes.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestTemporalMergePolicyForceMergeDeletes -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, run with TestTemporalMergePolicy*
TestTemporalMergePolicyForceMergeDeletes > testForceMergeDeletesSingleSegment FAILED
    java.lang.AssertionError: maxDoc after forceMergeDeletes expected:<1> but was:<2>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:4DFDDAB49229B500]:0)
        at org.junit.Assert.assertEquals(Assert.java:647)
        at org.apache.lucene.index.TestTemporalMergePolicyForceMergeDeletes.testForceMergeDeletesSingleSegment(TestTemporalMergePolicyForceMergeDeletes.java:43)
:lucene:core:test (FAILURE): 12 tests, 1  failure     # TestTemporalMergePolicy 7/7 and ...AcrossBuckets 4/4 pass

# with the recommended fix: 12 tests, 0 failures (assertions on and off)

# wider layouts (the verifiers' suites, same commit, seed DEADBEEF)
perSegment(maxDoc/del)=[10/5][10/5] expected:<0> but was:<10>          # one 50%-deleted segment in each of two windows
perSegment(maxDoc/del)=[10/0][10/5][10/0] expected:<0> but was:<5>     # one eligible among three in a window
deletes left after forceMergeDeletes (9 segs, maxThreshold 8): segments_a: _9(11.0.0):c40 _6(11.0.0):c10/5
deletes left after forceMerge(1): segments_2: _0(11.0.0):c10/5

Why it looks unintended

  • The contract asks for the merges needed "to expunge all deletes"; the policy's javadoc says "merge segments with more than 10% deleted documents", with no per-window minimum.
  • Documented exemptions: merges across windows and very old segments; rewriting one segment breaks neither.
  • TieredMergePolicy and LogMergePolicy rewrite a lone segment; the one test pinning the skip checks that windows stay apart.
The full argument249 words
  • The base contract. MergePolicy.findForcedDeletesMerges: "Determine what set of merge operations is necessary in order to expunge all deletes from the index" (MergePolicy.java:654-656). TemporalMergePolicy overrides it with no javadoc that narrows it (TemporalMergePolicy.java:527-528).
  • The policy's own rule is the percentage. setForceMergeDeletesPctAllowed: "When IndexWriter#forceMergeDeletes() is called, only merge segments whose delete percentage exceeds this threshold ... Default is 10.0 (merge segments with more than 10% deleted documents)" (:353-359).
  • The documented exemptions do not apply. The class NOTEs exempt merges across windows (:87-89) and segments older than maxAgeSeconds (:91-92; off by default, Long.MAX_VALUE, :106-107).
  • A one-segment merge is legal. OneMerge only needs a non-empty list (MergePolicy.java:226-229).
  • The other core policies do it. TestTieredMergePolicy has "Test singleton merge for expungeDeletes" (:407-415, LUCENE-7976); LogMergePolicy.findForcedDeletesMerges emits merges of any length, one included (LogMergePolicy.java:438, 450, 461). In the review of #15620, which added the policy, benwtrent noted that the PR summary did not say how "deletes and updates" are handled; the author answered that IndexWriter.forceMergeDeletes() "works as expected, but there are a couple of caveats"; benwtrent then said they would expect merging "with only expunging deletes" to work "as normal". The caveats in the PR description cover only natural merges and the same-window rule.
  • No way out under this policy. If the skip were deliberate, neither forceMergeDeletes() nor forceMerge(1) would reclaim a partially deleted lone segment while TemporalMergePolicy is active; only switching to another policy does.
The devil's-advocate check337 words

The strongest case for "intended": the policy's author wrote TestTemporalMergePolicy.testForcedDeletesKeepWindowsDisjoint (:200-235) with the code, already in the PR's first commit. It gives segB1 12% deletes against a 5% threshold, alone in its window, and asserts exactly one merge, of segA1 and segA2 only (:230-235). The guard is written five times; the javadoc's "only merge segments whose delete percentage exceeds" can be read as a necessary, not sufficient, condition; IndexWriter.forceMergeDeletes says the actual merges "are determined by the MergePolicy" (IndexWriter.java:2409-2410); the PR description says forced deletes "merges those within the same time window", which can be read as two or more; NoMergePolicy shows a policy may return no forced-deletes merge at all; and for append-heavy time-series data skipping a whole-segment rewrite may be a deliberate I/O trade-off (the author made that call for natural merges: "for us it wasn't worth the extra I/O"). Two of the cross-model reviews (refute and root-cause lenses, reading only) rated this strong, and so did the cross-model fact check of this doc; the intended-lens review and the intended-behaviour check rated it moderate.

Why it fails: the test's name and its sibling testForcedMergeKeepsWindowsDisjoint (:155-198) show what it checks, that no merge mixes windows; rewriting segB1 alone keeps that, and the updated assertion of the recommended fix still checks it. Nothing else documents the skip: no javadoc, CHANGES or MIGRATE text, no review comment in #15620. The javadoc's "Default is 10.0 (merge segments with more than 10% deleted documents)" says what gets merged, and the base contract asks for all deletes. The PR description's sentence describes the same-window rule and names no minimum of two; NoMergePolicy exists to merge nothing, while this policy implements forced deletes; and the threshold already gates the I/O, as in TieredMergePolicy. So the case is "probably not intended" (medium confidence), not settled. If maintainers still call it intended, the fallback is a javadoc change. For the forceMerge(1) twin the intended reading is stronger: a one-segment window already meets its segment-count goal.

Suggested fix

Recommended A: forced-deletes rewrites a lone eligible segment

Let forced-deletes rewrite a window's lone eligible segment on its own: in findForcedDeletesMerges skip only empty windows, and let buildSequentialMerges accept one candidate and emit a leftover batch of one. Change the one upstream assertion that pins the skip to expect two merges, one per window. Optionally do the same for forceMerge when the lone segment has deletes.

// findForcedDeletesMerges, per window
-      if (bucketSegments.size() < 2) {
+      if (bucketSegments.isEmpty()) {
// buildSequentialMerges
-    if (candidates.size() < 2) {
+    if (candidates.isEmpty()) {
-    if (batch.size() > 1) {
+    // a single leftover segment is rewritten on its own to expunge its deletes
+    if (batch.isEmpty() == false) {
All options and trade-offs3 options ยท 673 words

Three options. A was run with the repro, the policy's test classes (assertions on and off) and :lucene:core:check -x test; A + B also with TestTieredMergePolicy and TestLogMergePolicy; all on a scratch worktree of 645d4fe14a20, removed afterwards.

A: forced-deletes rewrites a lone eligible segment (validated by the checks) (recommended)

Three conditions in TemporalMergePolicy.java (:573, :952, :970) and the pinned assertion in TestTemporalMergePolicy.testForcedDeletesKeepWindowsDisjoint, which now expects two merges, {segA1, segA2} and {segB1}, so it still checks that no merge mixes windows.

// findForcedDeletesMerges, per window
-      if (bucketSegments.size() < 2) {
+      if (bucketSegments.isEmpty()) {
// buildSequentialMerges
-    if (candidates.size() < 2) {
+    if (candidates.isEmpty()) {
-    if (batch.size() > 1) {
+    // a single leftover segment is rewritten on its own to expunge its deletes
+    if (batch.isEmpty() == false) {

Trade-offs

  • Each lone eligible segment is rewritten on its own, at the cost of rewriting its size; that is what the caller asked for, and the threshold still gates it, as in TieredMergePolicy. It happens once: a second call finds no deletes and runs 0 merges.
  • The guard at :573 can never fire (windows are built from candidates), so it could also be removed.
  • findForcedDeletesMerges does not skip the old-data bucket (segments older than maxAgeSeconds, off by default), unlike findMerges and findForcedMerges; with the fix a lone old segment over the threshold is rewritten too. Whether forced deletes should respect maxAgeSeconds is a separate question (by reading, not run).
  • It changes one upstream assertion, and it leaves forceMerge as it is (option B).

What the checks validated

  • Stock: the repro fails (12 tests, 1 failure). With A: the repro, TestTemporalMergePolicy (7) and TestTemporalMergePolicyAcrossBuckets (4) pass with assertions on and off (12 tests, 0 failures); :lucene:core:check -x test passes with the repro test in the tree.
  • Without the assertion change the pinned test fails, expected:<1> but was:<2> (run on ea57360912ed, same file).
  • The verifiers' suites: the independent reproduction 8 of 8 pass (stock 4 of 8 fail); a separate test 3 of 4 (its forceMerge(1) test still fails, as expected). A retry probe: the first call runs 1 merge (maxDoc 1), the next two run 0.

B (add-on to A): forceMerge rewrites a window's lone segment that has deletes (validated by the checks)

In findForcedMerges (:507), a window with one segment gets a one-segment merge when mergeContext.numDeletesToMerge(segment) > 0; +8 lines. A lone segment without deletes is left alone, and the old-data bucket is still skipped (:503). This matches TieredMergePolicy, which bails out of a one-segment forceMerge(1) only when there are no deletes (TieredMergePolicy.java:834-849, from LUCENE-7976, and the merge-down-to-one case at :865-874, which LUCENE-8688 kept so that forceMerge of a single segment with deletes is not a no-op). An optional consistency change: a one-segment window already meets forceMerge(1)'s segment-count goal.

      if (bucketSegments.size() < 2) {
        // a window's lone segment is rewritten on its own only if it has deletes
        SegmentCommitInfo lone = bucketSegments.getFirst();
        if (mergeContext != null && mergeContext.numDeletesToMerge(lone) > 0) {
          if (spec == null) {
            spec = new MergeSpecification();
          }
          spec.add(new OneMerge(List.of(lone)));
        }
        continue;
      }

Trade-offs

Widens the change to forceMerge. forceMerge(n) with n > 1 can still leave deletes in a window that already has at most its share of segments; that is within forceMerge's contract and not touched (by reading).

What the checks validated

With A + B: TestTemporalMergePolicy*, TestTieredMergePolicy and TestLogMergePolicy pass (42 tests, 2 skipped, 0 failures); :lucene:core:check -x test passes; a separate test 4 of 4 (incl. forceMerge(1)), the independent reproduction 8 of 8 and the follow-up's residue probe 5 of 5 pass.

C: document the skip

Say in setForceMergeDeletesPctAllowed and the class NOTEs that a window with a single eligible segment is not merged.

Trade-offs

Closes the contract gap on paper but leaves no call under this policy that reclaims those deletes; the repro still fails. A fallback only if maintainers want today's behaviour.

Downstream note

An application that uses forceMergeDeletes() as a data-removal step on a TemporalMergePolicy index should check hasDeletions() afterwards: the deleted documents stay hidden from searches, but they remain in the index files and term statistics.

How it was found

Found by reading TemporalMergePolicy while a merge-policy hunt extended its model to this new policy, which its exhaustive check did not cover because the policy needs real timestamps per segment. An IndexWriter-level test on the stock build then confirmed it.

The method in full130 words

Method: non-formal (code reading, then executed tests).

  • Finding. A hunt over Lucene's merge policies had a bounded-exhaustive check over synthetic segments; TemporalMergePolicy reads point values from each segment, so it was outside that check. Reading its forced-deletes path showed the < 2 guards.
  • Confirmation. A JUnit through IndexWriter on the stock build (one segment with 5 of 10 deleted; two windows with one 50%-deleted segment each) failed with assertions on and off and on 10.5.0 to 10.5.2, and passed with the candidate fix.
  • Then cross-model reviews (OpenAI GPT-5.6, reading only), an independent reproducer with its own public-API suite and controls, a root-cause review, an intended-behaviour devil's advocate, an adjudication, a history and prior-report follow-up, a security screen, a main refresh, a repro-minimization step and this doc's gradle and fix runs.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26: 128 queries over apache/lucene issues, PRs and commits, the JIRA archive, four mailing lists, Elasticsearch, OpenSearch, Solr and the web, plus the files of all 413 open PRs; re-checked 2026-10-31). The only item naming the class is #15620, the PR that added it.

Searches2 searches

Verdict new: no issue, PR, commit, list thread, JIRA entry, downstream report or web page describes a window with one eligible segment or a leftover batch of one. Nearest items, not this bug: #15620 (the introducing PR; its review discussed deletes only in general, see "Why it looks unintended"), #15412 (the feature request, no deletes discussion), LUCENE-7976 / #9025 and LUCENE-8688 / #9734 (TieredMergePolicy single-segment expunge and forceMerge: precedent for the fix), #16701 (LogMergePolicy natural merges leave deletes: different code). TemporalMergePolicy.java has had no commit since #15620, and no open PR touches it. Limits: keyword searches only; the JIRA archive ends in 2022, before the class existed.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; the JIRA archive; Elasticsearch, OpenSearch, Solr; the web2026-10-26scripted prior-report search (16 GitHub issue/PR, 3 commit, 2 downstream, 20 list, 4 JIRA, 6 web queries; 406 hits), 51 GitHub GraphQL searches in all states, downstream code search, the changed files of all 413 open PRs, the file history on main, branch_10x and branch_10_6no report; context: #15620; precedent: #9025, #9734; different code: #16701
apache/lucene issues and PRs2026-10-31read-only gh search: "TemporalMergePolicy", "temporal merge policy", open PRs "TemporalMergePolicy", "forceMergeDeletes singleton", "forceMergeDeletes time window", "TemporalMergePolicy forceMergeDeletes"only #15620

History

Original code of the policy: #15620 (merged 2026-03-17) added it with these guards, already in the PR's first commit. Every release that has the class fails: 10.5.0, 10.5.1, 10.5.2 and 10.6.0, and main. 10.4.0 and earlier do not have the class. Not fixed upstream.

Versions tested4 rows

Introduced by 1596b7604b67 (apache/lucene#15620, "Add Temporal Merge Policy for time-series data", merged to main 2026-03-17); 10.x backport 4a2c3fd0901c, contained in the 10.5.0, 10.5.1, 10.5.2 and 10.6.0 tags. git log --follow lists only this commit for the file and git blame attributes all five guard lines to it. The guards and the pinning test are already in the PR's first commit bd45e5dbce8e (authored 2026-01-26, committed 2026-01-27): not a regression.

CHANGES.txt lists GITHUB#15620 under the 10.4.0 heading, but 10.4.0 was tagged before the merge and has no such class, so it does not date the bug. Upstream main 2f424fd428da (2026-10-30) has the same file.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test: expected:<1> but was:<2>, assertions on and off (standalone and gradle)
10.6.0 affectedminimal test fails the same way on the release jars
main ea57360912ed; 10.5.0, 10.5.1, 10.5.2 affectedthe finder's 2 tests fail; the independent suite fails its 4 bug cases, its 4 controls pass (10.5.2 and main also with assertions off)
10.4.0, 9.12.3 and earlier n/athe class does not exist (compile error)

Independent checks

Reproduced by the finder, an independent reproducer with its own controls, a root-cause verifier, an intended-behaviour devil's advocate, the adjudication, a main refresh, the repro-minimization step and this doc. No cross-model review could run Java; of the three, two called it real, and the refute lens returned a procedural "not real" over the pinned test.

Checks and verdicts394 words

Every reproduction ran on stock builds; the fix checks ran with the patched TemporalMergePolicy. The cross-model reviewers (OpenAI GPT-5.6) only read the code; a separate run re-ran the repro, which failed as claimed.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (code reading, then an IndexWriter-level JUnit) candidate2 tests fail on main (-ea, -da), 10.5.0 and 10.5.2; pass with the candidate fix shadowed.
Cross-model reviews before the candidate (OpenAI GPT-5.6, reading only) intended lens: real (medium); refute lens: not real (medium, procedural)Neither could run Java; both agreed on the mechanism. The refute lens rated the pinned test a strong intended argument.
Independent reproducer realOwn public-API suite: 4 bug layouts fail, 4 controls pass (TieredMergePolicy on the same layouts; two eligible segments in one window) on main, 10.5.0 and 10.5.2; its own patch passes 8 of 8.
Root-cause review (OpenAI GPT-5.6, reading only) and verifier test real (medium)Root cause agreed; rated the intended argument strong; the candidate fix judged incomplete (the forceMerge twin, the pinned assertion). The verifier's test fails 2 of 4 on main and 10.5.2.
Intended-behaviour devil's advocate real (medium-high)Intended reading moderate: it rests on one incidental assertion. Its controls reproduce the leftover singleton and the forceMerge(1) twin.
Adjudication confirmed (medium), minorRe-ran the finder's test and the independent reproduction's suite with a new seed, assertions on and off; the fix check passes.
Main refresh (a9779ef3c193) still reproducesThe finder's 2 tests fail with byte-identical messages.
Repro minimization (2026-10-29) verifiedCut to one test of 2 documents; fails on 645d4fe14a20 with two seeds, assertions on and off; passes with the fix.
This doc (645d4fe14a20, 10.6.0) reproduces; fixes validatedMinimal test fails on main and 10.6.0; gradle stock 12 tests, 1 failure; fix A 12 of 12, A + B 42 tests, 0 failures; check -x test passes with each.
Fact checks of this doc (2026-11-01) bug and repro confirmed; corrections appliedOne check re-ran the repro on main with assertions on and off, on 10.5.0 and 10.6.0, the gradle command and fix A, and ran the other core policies on the same test. The other (OpenAI GPT-5.6, reading only) rated the intended argument strong. Both found wording and provenance errors, now fixed; none in the mechanism or the repro.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,063 words

Finding record

LFV-213, snapshot of 2026-11-06

Registry title
TemporalMergePolicy.forceMergeDeletes never expunges deletes from a segment that is the only eligible one in its time window (or the odd one left after full batches)
Area
index / merge policy (TemporalMergePolicy forced-deletes selection, IndexWriter.forceMergeDeletes)
Priority
Low, rank 1009: TemporalMergePolicy never expunges deletes from a window's lone eligible segment, an experimental 10.5 policy and disk space only.
Impact class
minor
Impact note
Applications that choose TemporalMergePolicy (opt-in, @lucene.experimental, first released in 10.5.0) and call forceMergeDeletes() keep the deleted documents of every partially deleted segment that is the only segment over forceMergeDeletesPctAllowed in its time window, with default policy settings and at any scale (forceMerge(1) has the same gap); the cost is disk space and search work, the deleted documents stay hidden from searches, nothing is lost or corrupted, and repeating the call does not help.
Summary
You use TemporalMergePolicy and call IndexWriter.forceMergeDeletes() while some time window holds exactly one segment whose share of deleted documents is above forceMergeDeletesPctAllowed (default 10%), for example in a single-segment index, or after forceMerge(1), which leaves one segment per window. You expect that segment to be rewritten without its deleted documents, as the policy's setForceMergeDeletesPctAllowed javadoc and MergePolicy.findForcedDeletesMerges ('expunge all deletes') describe and as TieredMergePolicy and LogMergePolicy do. Instead the policy skips every window with fewer than two eligible segments (and drops the one left over after full batches of maxThreshold), so the call schedules no merge and returns at once: a 10-document segment with 5 deletes still has maxDoc 10, and calling it again changes nothing.
Introduced
1596b7604b67ed47314aa648f14b91dc75ce8134 (apache/lucene#15620 'Add Temporal Merge Policy for time-series data', merged to main 2026-03-17; branch_10x backport 4a2c3fd0901cbd4a31704b619e0dde15ac65f284, contained in 10.5.0, 10.5.1, 10.5.2 and 10.6.0) [read: git log --follow in the full-history clone lists exactly this one commit for TemporalMergePolicy.java, and git blame attributes every guard line to it: findForcedDeletesMerges :573, buildSequentialMerges :952 and :970, and the forceMerge twin findForcedMerges :507 and buildForcedMerges :925; the guards and the upstream test that pins the outcome are already in the PR's first commit bd45e5dbce8e (2026-01-27)]. Original code, not a regression: there was never a correct earlier behaviour (2026-03-17 (merge of #15620; the guards date from the PR's first commit, 2026-01-27))
First affected version
10.5.0 [exec] (first release with TemporalMergePolicy); 10.4.0 and 9.12.3 do not have the class [exec: compile error, cannot find symbol]. CHANGES.txt lists GITHUB#15620 under the 10.4.0 heading, but 10.4.0 (tagged before the merge) does not contain the class, so CHANGES.txt cannot date this bug
Affected versions
10.5.0, 10.5.1, 10.5.2, 10.6.0 (released 2026-10-29; [exec 2026-10-31] the minimal test fails on the 10.6.0 release jars (SHA-1 checked against Maven Central), assertions on) and main ea57360912ed. [exec] the finder's JUnit fails 2 of 2 and the independent reproduction's suite fails 4 of 8 (all four bug cases fail, all four controls pass) on 10.5.0, 10.5.1, 10.5.2 and main with -ea, and also with -da on 10.5.2 and main; a separate JUnit fails on main (-ea, -da) and 10.5.2, and the intended-behaviour check's own controls fail on main (-ea, -da). [read] branch_10x and branch_10_6: TemporalMergePolicy.java blob b3e4e927d4a6 is identical to 10.5.2 in a local mirror fetched 2026-10-24 (not executed); the 10.x file differs from main only in a lambda parameter name. Not applicable: 10.4.0 and every earlier release (class absent). All runs on aarch64 with JDK 25.0.3
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (2026-10-26, read-only: a scripted prior-report search with 16 GitHub issue/PR queries on apache/lucene, 3 commit queries, 2 downstream-repo queries, 20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA-archive queries and 6 web queries, 0 errors, 406 distinct hits; 51 GitHub GraphQL searches over issues and PRs in all states; downstream searches in Elasticsearch, OpenSearch and Solr; the touched files of all 413 open apache/lucene PRs; 128 queries in total, none the same bug). The only apache/lucene item that names the class is #15620, the PR that added it, whose discussion never mentions a window with one eligible segment or the trailing batch of one; TemporalMergePolicy.java has had no commit since on main, branch_10x or branch_10_6, and no open PR touches it. The upstream test testForcedDeletesKeepWindowsDisjoint (from #15620) pins the current outcome without discussing it.
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test with assertions on and off, and gradle on a scratch worktree, stock and with both fixes; the 10.6.0 release jars. The hunt and its verifiers ran on main ea57360912ed and on 10.5.0, 10.5.1 and 10.5.2. TemporalMergePolicy.java is byte-identical on both mains and on upstream main 2f424fd428da (2026-10-30); the 10.5.2 and 10.6.0 copy differs only in one lambda parameter name.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/TemporalMergePolicy.java:83-94 (class NOTEs, @lucene.experimental), :352-360 (setForceMergeDeletesPctAllowed javadoc), :507 (findForcedMerges guard), :528-598 (findForcedDeletesMerges, guard at :573), :925 (buildForcedMerges guard), :951-978 (buildSequentialMerges, :952 and :970) at ea57360912ed
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:654-656 (findForcedDeletesMerges contract); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2408-2411 (forceMergeDeletes javadoc)
  • lucene/core/src/test/org/apache/lucene/index/TestTemporalMergePolicy.java:200-235 (testForcedDeletesKeepWindowsDisjoint, which pins the current outcome)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/TemporalMergePolicy.java:41-45, 87-89, 108, 111, 353-359, 527-598 (guard :573-575), 503-509, 833, 925-927, 951-978 (:952-954, :970)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2345-2406
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2369, 2508, 4378-4379, 6722; TieredMergePolicy.java:947, 960; TemporalMergePolicy.java:796-809
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/TemporalMergePolicy.java:87-89, 94, 833
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2413
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:226-229, 654-656
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/TemporalMergePolicy.java:87-92, 106-107, 353-359, 527-528
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestTemporalMergePolicy.java:155-198, 200-235
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestTieredMergePolicy.java:407-415
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/LogMergePolicy.java:409-464
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2409-2411
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:834-849, 865-874
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/TemporalMergePolicy.java:503-509, 573, 829, 952, 970
Diagrams
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/TemporalMergePolicy.java:568-598, 951-978

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the executed runs.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.