- 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. 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, soforceMerge(1)leaves one segment per window.
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.- Index with
new TemporalMergePolicy().setTemporalField("ts")and default settings: 2 documents with aLongPointtimestamp, thencommit(). The index has one segment, alone in its time window. - Delete 1 of the 2 documents (50%, above the default
forceMergeDeletesPctAllowedof 10%) and callforceMergeDeletes(). You expect the segment to be rewritten without it:maxDoc1, asLogMergePolicydoes in the same test. - Instead no merge is scheduled and
maxDocstays 2. Three calls in a row run 0 merges each, andforceMerge(1)does not rewrite the segment either. - 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.
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.
- Time window:
TemporalMergePolicyassigns each segment to a window by the largest value of itsLongPointtimestamp field. Recent windows are small (1 hour by default) and older ones grow by a factor ofminThreshold(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 duringforceMerge(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 aboveforceMergeDeletesPctAllowed(default10.0,:111, javadoc:353-359).
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 callsfindForcedDeletesMergesonce (:2359). If the policy returns nothing, no forced merge runs and the call returns without waiting (:2369,:2405).TemporalMergePolicy.findForcedDeletesMerges(:527-598) computespctDeletes = (100.0d * delCount) / maxDoc(:549-554), keeps the segments above the threshold (:555) and groups them by window (:568).- Per window:
if (bucketSegments.size() < 2) { continue; }(:573-575). A window with one eligible segment is dropped, whatever else it holds. - Windows with two or more go to
buildSequentialMerges(:951-978). It returnsnullfor fewer than two candidates (:952-954), emits batches of exactlymaxThreshold(default 8,:108), and emits the last batch onlyif (batch.size() > 1)(:970). So 9 eligible segments in a window leave the 9th with its deletes. - 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 (allbaseTimeSecondswide,: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. - The
forceMergetwin.findForcedMerges(:507-509) andbuildForcedMerges(:925-927) have the same< 2guard, soforceMerge(1)does not rewrite a lone segment with deletes either.forceMergestill keeps its promise (at most N segments, never across windows); only the deletes stay.
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 callforceMergeDeletes()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,docFreqand 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 aTieredMergePolicywithsetForceMergeDeletesPctAllowed(0)and callingforceMergeDeletes(true)removed the deletes. Then restore theTemporalMergePolicyand commit (not run). With the default concurrent scheduler checkhasDeletions()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
IndexWriteranyway. - 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
forceMergeDeletesis an explicit call thatIndexWriter's javadoc says "rarely is it warranted".
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).
/*
* 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);
}
}
}
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestTemporalMergePolicyForceMergeDeletes -Ptests.seed=DEADBEEF
- Expected
forceMergeDeletes()rewrites the 50%-deleted segment on its own:maxDoc1.- Observed
- No merge is scheduled:
maxDoc after forceMergeDeletes expected:<1> but was:<2>.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux, aarch64 |
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
# 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- 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.
TieredMergePolicyandLogMergePolicyrewrite 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).TemporalMergePolicyoverrides 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 thanmaxAgeSeconds(:91-92; off by default,Long.MAX_VALUE,:106-107). - A one-segment merge is legal.
OneMergeonly needs a non-empty list (MergePolicy.java:226-229). - The other core policies do it.
TestTieredMergePolicyhas "Test singleton merge for expungeDeletes" (:407-415, LUCENE-7976);LogMergePolicy.findForcedDeletesMergesemits 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 thatIndexWriter.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()norforceMerge(1)would reclaim a partially deleted lone segment whileTemporalMergePolicyis 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.
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.
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
:573can never fire (windows are built from candidates), so it could also be removed. findForcedDeletesMergesdoes not skip the old-data bucket (segments older thanmaxAgeSeconds, off by default), unlikefindMergesandfindForcedMerges; with the fix a lone old segment over the threshold is rewritten too. Whether forced deletes should respectmaxAgeSecondsis a separate question (by reading, not run).- It changes one upstream assertion, and it leaves
forceMergeas it is (option B).
What the checks validated
- Stock: the repro fails (12 tests, 1 failure). With A: the repro,
TestTemporalMergePolicy(7) andTestTemporalMergePolicyAcrossBuckets(4) pass with assertions on and off (12 tests, 0 failures);:lucene:core:check -x testpasses with the repro test in the tree. - Without the assertion change the pinned test fails,
expected:<1> but was:<2>(run onea57360912ed, 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 (maxDoc1), 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.
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.
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.
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;
TemporalMergePolicyreads point values from each segment, so it was outside that check. Reading its forced-deletes path showed the< 2guards. - Confirmation. A JUnit through
IndexWriteron 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; the JIRA archive; Elasticsearch, OpenSearch, Solr; the web | 2026-10-26 | scripted 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_6 | no report; context: #15620; precedent: #9025, #9734; different code: #16701 |
| apache/lucene issues and PRs | 2026-10-31 | read-only gh search: "TemporalMergePolicy", "temporal merge policy", open PRs "TemporalMergePolicy", "forceMergeDeletes singleton", "forceMergeDeletes time window", "TemporalMergePolicy forceMergeDeletes" | only #15620 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test: expected:<1> but was:<2>, assertions on and off (standalone and gradle) |
| 10.6.0 | affected | minimal test fails the same way on the release jars |
main ea57360912ed; 10.5.0, 10.5.1, 10.5.2 | affected | the 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/a | the class does not exist (compile error) |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (code reading, then an IndexWriter-level JUnit) | candidate | 2 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 | real | Own 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), minor | Re-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 reproduces | The finder's 2 tests fail with byte-identical messages. |
| Repro minimization (2026-10-29) | verified | Cut 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 validated | Minimal 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 applied | One 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. |
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 458 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,063 words
- 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 mainea57360912edand on 10.5.0, 10.5.1 and 10.5.2.TemporalMergePolicy.javais byte-identical on both mains and on upstream main2f424fd428da(2026-10-30); the 10.5.2 and 10.6.0 copy differs only in one lambda parameter name.
- 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 ea57360912edlucene/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-2406a 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, 833a 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-656a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/TemporalMergePolicy.java:87-92, 106-107, 353-359, 527-528a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestTemporalMergePolicy.java:155-198, 200-235a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestTieredMergePolicy.java:407-415a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/LogMergePolicy.java:409-464a 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-874a 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
- TestTemporalMergePolicyForceMergeDeletes.java:
repro/LFV-213/src/org/apache/lucene/index/TestTemporalMergePolicyForceMergeDeletes.java(46 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed runs.
assets/LFV-213/bug.png(458 KB)
