- A Lucene index is a set of segments. Each segment records the Lucene version that wrote it and its codec (file format): a segment written by 9.0.0 has version 9.0.0 and codec
Lucene90. - Lucene 11 still reads such segments, through
lucene-backward-codecs. A segment moves to the current format only when a merge copies it into a new segment. forceMerge(n)asks the merge policy to merge until at most n segments remain. The policy picks the merges, and when the index is already at n segments with nothing to clean up it may pick none.
forceMerge(1) (left unchanged) and through UpgradeIndexMergePolicy (rewritten to 11.0.0). Select the still to open it at full size. Generated with AI, checked against the code.- You move to Lucene 11 an index that Lucene 9.0.0 wrote and that was force-merged long ago: one segment, 50 documents, no deletions, codec
Lucene90. You follow MIGRATE.md's "Upgrade Example": open anIndexWriterwith a defaultIndexWriterConfigand callforceMerge(1). - You expect what the comment promises, "Rewrites all segments to latest format": one segment at version 11.0.0, codec
Lucene104. - Instead nothing happens.
forceMerge(1)only promises at most one segment, and the defaultTieredMergePolicytreats one segment without deletions as "already merged" and picks no merge. The call returns normally; the segment is still 9.0.0 with codecLucene90. - So the index still needs
lucene-backward-codecs: opened without it, it fails withIndexFormatTooOldException("Could not load codec 'Lucene90'"). With the policy wrapped inUpgradeIndexMergePolicy, the tool made for this, the same call rewrites it to 11.0.0, and it opens with lucene-core alone. - Indexes with two or more segments or any deletion are rewritten as the guide says; only the one-segment, no-deletion shape (what a force-merged static index looks like) is skipped.
Anyone who upgrades a 9.x or 10.x index to 11.0 with the guide's optional forceMerge(1) step when the index already has one segment and no deletions, the shape of a force-merged static index. Default config, every time, silently. The index stays readable and its lifetime is unchanged; 9.x and 10.0-10.3 segments keep needing lucene-backward-codecs. Unreleased.
Mechanism, terms and numbers603 words
Lucene main's MIGRATE.md, in the 11.0 section "Relaxed Index Upgrade Policy", tells users who open an index created with Lucene 9.x or 10.x how to upgrade its segment formats: Scenario 1 says "Optional: Call forceMerge() or use UpgradeIndexMergePolicy to upgrade segment formats immediately", and the "Upgrade Example" opens new IndexWriter(dir, config) and calls writer.forceMerge(1); // Rewrites all segments to latest format.
forceMerge(1) only promises to leave at most one segment, and the merge policy picks the merges. The default TieredMergePolicy returns no merge for an index that already has one segment and no deletions ("already merged"), so the old segment is never rewritten and the call returns normally. On Lucene's own backward-compatibility index moreterms.9.0.0 (one segment, 50 documents) the segment is still version 9.0.0 with codec Lucene90 afterwards; without lucene-backward-codecs that index then fails to open with IndexFormatTooOldException. Indexes with two or more segments or any deletion are rewritten as promised.
The no-op is old and deliberate (LUCENE-3082 added IndexUpgrader and UpgradeIndexMergePolicy for exactly this case in 2011), so the code is correct and only the guide is wrong. The text came with #15012 (2025-09-18) and is on main and on the 11.0 release branch branch_11x, not in any release. Fix: wrap the configured merge policy in UpgradeIndexMergePolicy in the example and correct Scenario 1; with that one line the same test passes.
- Line 153 (Scenario 1): "Optional: Call
forceMerge()or useUpgradeIndexMergePolicyto upgrade segment formats immediately". - Lines 169-183, "Upgrade Example": "// Opening an index created with Lucene 9.x or 10.x in Lucene 11.x+", then
try (IndexWriter writer = new IndexWriter(dir, config)) { writer.forceMerge(1); // Rewrites all segments to latest format }(line 179).configis not defined in the snippet. - On today's upstream main and
branch_11xthe same block sits 14 lines lower (lines 167 and 193), unchanged.
IndexWriter.forceMergejavadoc (IndexWriter.java:2187-2189): "Forces merge policy to merge segments until there are <= maxNumSegments. The actual merges to be executed are determined by the MergePolicy." It ties the call to static indexes: "usually you should only call this if the index is static" (:2191-2193).forceMergemarks every current segment as an original segment to merge (IndexWriter.java:2251-2255).new IndexWriterConfig()usesTieredMergePolicy(LiveIndexWriterConfig.java:143).TieredMergePolicy.findForcedMerges(TieredMergePolicy.java:834-847): with no deletions,maxSegmentCount == 1, one eligible segment and that segment insegmentsToMerge, it logs "already merged" and returnsnull. The segment's version and codec play no part.UpgradeIndexMergePolicy(UpgradeIndexMergePolicy.java:26-31) "is used for upgrading all existing segments of an index when calling IndexWriter#forceMerge(int)": it selects every segment whose version is notVersion.LATEST(:68-70) and merges the ones its delegate declines itself (:117-137).IndexUpgrader.upgrade()uses it (IndexUpgrader.java:180,:196).- The
LogMergePolicyvariants also skip a lone segment without deletions when its compound-file flag already matches (MergePolicy.isMerged,MergePolicy.java:750-758).
On the stock main jars, 10 index states (Lucene's own fixtures plus states derived from them) x 6 policy setups = 60 runs. A decision model written from the code predicts all 60. The guide's claim holds in 50 and fails in 10, all on a single segment without deletions: both TieredMergePolicy setups on the 9.0.0, 9.12.3 and 10.5.2 fixtures, and both LogMergePolicy setups on the compound 9.12.3 and 10.5.2 fixtures. The other 8 single-segment, deletion-free runs are rewritten (UpgradeIndexMergePolicy and IndexUpgrader on all three, and the LogMergePolicy setups on the non-compound 9.0.0 fixture). UpgradeIndexMergePolicy and IndexUpgrader rewrite all 20 of their cases; document counts are unchanged in all 60.
Exposure and likelihood236 words
- Who. Users upgrading an existing 9.x or 10.x index to 11.0 by the guide's optional "upgrade segment formats" step, whose index already has a single segment without deletions.
forceMerge's javadoc ties the call to static indexes ("usually you should only call this if the index is static"), and a static index force-merged to one segment has exactly this shape. - Default vs opt-in. The default
IndexWriterConfig(TieredMergePolicy), the example as written. The upgrade step itself is optional. - Silent. The call returns normally; only an enabled
InfoStreamshows "already merged". - What stays wrong. For 9.x and 10.0-10.3 indexes the segment keeps a codec that lives in
lucene-backward-codecs; an application that drops that jar after "upgrading" getsIndexFormatTooOldException ... Could not load codec 'Lucene90' ... Did you forget to add lucene-backward-codecs.jar?. After the corrected example the same index opens with lucene-core alone (numDocs=50). 10.4 and 10.5 segments already useLucene104and keep only an older version stamp and older sub-format versions. - Not affected. No data is lost or changed, the index stays readable, and how long it stays openable is unchanged (that depends on the creation version and
MIN_SUPPORTED_MAJOR). Multi-segment indexes and indexes with deletions are rewritten. - Scale and timing. Deterministic for any such index the writer configuration can open (20 of 20 random seeds). Unreleased: main and the 11.0 release branch only, so it would first reach users in 11.0.0.
One JUnit test in the backward-codecs test package: it unzips Lucene's own one-segment 9.0.0 test index, runs the guide's forceMerge(1) with a default IndexWriterConfig and checks the segment's version. Public API only, no randomness of its own.
/*
* 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.backward_index;
import java.nio.file.Path;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.SegmentInfos;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.Version;
public class TestMigrateUpgradeExample extends LuceneTestCase {
public void testForceMergeUpgradesSingleSegmentIndex() throws Exception {
Path indexPath = createTempDir();
TestUtil.unzip(getDataInputStream("moreterms.9.0.0.zip"), indexPath);
try (Directory dir = FSDirectory.open(indexPath)) {
IndexWriterConfig config = new IndexWriterConfig();
try (IndexWriter writer = new IndexWriter(dir, config)) {
writer.forceMerge(1);
}
// MIGRATE.md: forceMerge(1) "Rewrites all segments to latest format"; actual: the 9.0.0 segment is kept
assertEquals(Version.LATEST, SegmentInfos.readLatestCommit(dir).info(0).info.getVersion());
}
}
}
# $LUCENE: a Lucene main checkout
cp TestMigrateUpgradeExample.java $LUCENE/lucene/backward-codecs/src/test/org/apache/lucene/backward_index/
cd $LUCENE
./gradlew :lucene:backward-codecs:test --tests org.apache.lucene.backward_index.TestMigrateUpgradeExample -Ptests.seed=DEADBEEF
# assertions off, same result: add -Ptests.asserts=false
- Expected
- After
forceMerge(1)the only segment is at version 11.0.0 (Version.LATEST). - Observed
expected:<11.0.0> but was:<9.0.0>: segment_0keeps version 9.0.0 and codecLucene90, with assertions on and off.
More files, environment, full output175 words
The test must sit in package org.apache.lucene.backward_index of the backward-codecs test sources: getDataInputStream("moreterms.9.0.0.zip") loads the fixture next to the test class.
Regression test: optional. If wanted, it is this same test with the corrected example's one extra line, config.setMergePolicy(new UpgradeIndexMergePolicy(config.getMergePolicy()));, which passes with the fix and pins the recipe the guide shows (the test then also imports org.apache.lucene.index.UpgradeIndexMergePolicy). Upstream already tests upgrades through IndexUpgrader.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); upstream main and branch_11x carry the same text (read) |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 (merge-selection logic; the platform cannot change the result) |
# ./gradlew :lucene:backward-codecs:test --tests org.apache.lucene.backward_index.TestMigrateUpgradeExample -Ptests.seed=DEADBEEF
# main 645d4fe14a20, assertions on (-Ptests.asserts=false gives the same failure)
TestMigrateUpgradeExample > testForceMergeUpgradesSingleSegmentIndex FAILED
java.lang.AssertionError: expected:<11.0.0> but was:<9.0.0>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:5CDB23FA4FF1066B]:0)
at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:120)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:146)
at org.apache.lucene.backward_index.TestMigrateUpgradeExample.testForceMergeUpgradesSingleSegmentIndex(TestMigrateUpgradeExample.java:40)
...
1 test completed, 1 failed
# the index before and after, same jars (finder's matrix)
default(TMP): before _0 ver=9.0.0 codec=Lucene90 maxDoc=50 del=0 -> after _0 ver=9.0.0 codec=Lucene90 (unchanged)
Upgrade(TMP): before _0 ver=9.0.0 codec=Lucene90 maxDoc=50 del=0 -> after _1 ver=11.0.0 codec=Lucene104 maxDoc=50- The guide says it unconditionally: the comment on
forceMerge(1)reads "Rewrites all segments to latest format", and Scenario 1 offers forceMerge() as an alternative toUpgradeIndexMergePolicy. - The no-op is deliberate and old: LUCENE-3082 added
UpgradeIndexMergePolicyin 2011 because "optimizing a one-segment index is a no-op without the special merge-policy" (Uwe Schindler).
The full argument199 words
- Unconditional text. Line 179 credits the rewrite to
forceMerge(1)itself, and line 153 offers "CallforceMerge()or useUpgradeIndexMergePolicy" as two alternatives. - No recorded decision to accept this limitation. markrmiller's proposal comment on #13797 says "Merging rewrites segments to the current codec" and lists "optional forceMerge" as the upgrade step. Uwe Schindler's comment there named IndexUpgrader / UpgradeIndexMergePolicy as the existing rewrite path, but that did not reach MIGRATE.md. The #15012, #16741 and #16756 threads never mention forceMerge, single segments or IndexUpgrader.
- The code is intended, and documented as such elsewhere. LUCENE-3082 (#4155, 2011) added IndexUpgrader and UpgradeIndexMergePolicy; as Uwe Schindler put it there, "The optimize() approach fails if your index is already optimized" and "optimizing a one-segment index is a no-op without the special merge-policy". LUCENE-5526 (#6589, 2014) says forcing a merge of a lone segment needs "a custom merge policy such as UpgradeIndexMergePolicy". LUCENE-5481 (#6544, 2014) removed unneeded forced merges, and
BaseMergePolicyTestCase.testForceMergeNotNeededfails any policy that merges when nothing is needed. Lucene's own upgrade tests use IndexUpgrader, not a bare forceMerge. - Every completed review called it real: an independent reproducer, a reviewer from another model family (OpenAI GPT-5.6) under lenses, a devil's-advocate check and the adjudication.
The devil's-advocate check147 words
Strongest case for "intended" or "noise": forceMerge is a segment-count operation, never a format converter, as its javadoc, TieredMergePolicy's "already merged", LUCENE-5481 and testForceMergeNotNeeded all say. The snippet leaves config undefined, so the reader may be meant to supply an UpgradeIndexMergePolicy. Line 152 already says segments are upgraded "gradually through normal merging", and the index works either way.
Why it fails: the first point defends the code, which nobody proposes to change; it does not make the sentence true. The undefined-config reading is ruled out by the guide itself, because line 153 presents forceMerge() as an alternative to UpgradeIndexMergePolicy and the comment on line 179 is unconditional. "It still works" only lowers the severity, which is already Low: a reader who runs the guide's recipe is told the index is in the latest format when it is not. Verdict: a real, minor documentation defect.
Recommended Wrap the merge policy in UpgradeIndexMergePolicy in the example, and correct Scenario 1 (validated)
Docs only. In the example, wrap the configured policy before opening the writer, config.setMergePolicy(new UpgradeIndexMergePolicy(config.getMergePolicy()));, and make Scenario 1 point to UpgradeIndexMergePolicy or IndexUpgrader, noting that a plain forceMerge(1) skips a single segment without deletions. Validated: the same test passes with that line.
-- Optional: Call `forceMerge()` or use `UpgradeIndexMergePolicy` to upgrade segment formats immediately
+- Optional: Use `IndexUpgrader` or `UpgradeIndexMergePolicy` to upgrade segment formats immediately (a plain `forceMerge(1)` does not rewrite an index that already has a single segment without deletions)
@@
// Optional: Upgrade segment formats
+ config.setMergePolicy(new UpgradeIndexMergePolicy(config.getMergePolicy()));
try (IndexWriter writer = new IndexWriter(dir, config)) {
- writer.forceMerge(1); // Rewrites all segments to latest format
+ writer.forceMerge(1); // Rewrites all segments written by older versions to the latest format
}All options and trade-offs4 options ยท 597 words
One fix was validated, with the two-hunk diff applied to 645d4fe14a20; the alternatives were not run.
Wrap the merge policy in UpgradeIndexMergePolicy in the example, and correct Scenario 1 (validated) (recommended)
Two hunks in lucene/MIGRATE.md; it also applies to today's upstream main (14 lines lower).
-- Optional: Call `forceMerge()` or use `UpgradeIndexMergePolicy` to upgrade segment formats immediately
+- Optional: Use `IndexUpgrader` or `UpgradeIndexMergePolicy` to upgrade segment formats immediately (a plain `forceMerge(1)` does not rewrite an index that already has a single segment without deletions)
@@
// Optional: Upgrade segment formats
+ config.setMergePolicy(new UpgradeIndexMergePolicy(config.getMergePolicy()));
try (IndexWriter writer = new IndexWriter(dir, config)) {
- writer.forceMerge(1); // Rewrites all segments to latest format
+ writer.forceMerge(1); // Rewrites all segments written by older versions to the latest format
}Trade-offs
UpgradeIndexMergePolicyrewrites only old segments: an index that also has new-format segments can end with more than one segment, all at the latest version. That is what an upgrade step needs.- Its javadoc warns it may reorder documents if the index was partially upgraded; not an issue for a single segment.
- Its own class-javadoc example calls a removed constructor (LFV-1021), so a reader the guide sends there meets that error too.
- With a
SoftDeletesRetentionMergePolicyinside, its leftover merge bypasses retention (LFV-617); at most a caveat. UpgradeIndexMergePolicyis marked@lucene.experimental; it has existed since 2011 and is whatIndexUpgraderuses.- The snippet stays a sketch: it still leaves
configto the reader, and real code also needsimport org.apache.lucene.index.UpgradeIndexMergePolicy. Whateverconfigis must match the index (for example its index sort or parent field), ornew IndexWriter(dir, config)fails beforeforceMergeruns; that holds for the current example too.
What the checks validated
- The diff applies cleanly to
645d4fe14a20and, with an offset of 14 lines, to today's upstream main. - The minimal test with the fix's one extra line passes 1 of 1 through the same Gradle command with assertions on and off; the stock example fails it, so the test detects the bug. After the corrected example the index opens with lucene-core alone.
TestIndexUpgradeBackwardsCompatibility,TestUpgradeIndexMergePolicyandTestTieredMergePolicypass (340 + 5 + 17 tests, 0 failures); a sanity check, since the change is documentation only../gradlew :lucene:documentation:markdownToHtmlsucceeds and the rendered page shows the corrected text.
new IndexUpgrader(dir).upgrade() or the command line java -cp lucene-core.jar:lucene-backward-codecs.jar org.apache.lucene.index.IndexUpgrader indexDir. Simple and already tested upstream. A 9.x or 10.0-10.3 index needs lucene-backward-codecs on the class path to be read at all: with lucene-core alone the command fails with IndexFormatTooOldException ("Could not load codec 'Lucene90'"); with both jars it prints "All segments upgraded to version 11.0.0". (IndexUpgrader's own javadoc shows only lucene-core.jar.)
Trade-offs
It refuses an index with more than one commit unless told to delete prior commits, keeps only the last commit, and its convenience constructors use a bare IndexWriterConfig, which drops an index sort and cannot upgrade an index with a parent field (LFV-615, known upstream). The recommended option keeps the application's own config.
Say that forceMerge(1) rewrites only the segments it merges, and that an index already at one segment without deletions needs UpgradeIndexMergePolicy or IndexUpgrader.
Trade-offs
Correct, but leaves the guide without a working recipe for the single-segment, deletion-free shape of a force-merged static index.
Change TieredMergePolicy (and the other policies) to merge a lone segment whose version is not current.
Trade-offs
Contradicts LUCENE-5481 and testForceMergeNotNeeded, changes the cost of every forceMerge, and duplicates UpgradeIndexMergePolicy. Every completed review agreed the code should not change.
Downstream products whose index-upgrade step is a plain force merge to one segment see the same no-op on single-segment shards; it was raised in Elasticsearch in 2014 (elastic/elasticsearch#5243, "Force single-segment merges").
No formal method. A probe ran the guide's upgrade example against an index written by 9.12.3 and saw its single segment stay in the old format while a three-segment index was rewritten. The hunt then checked a decision model of forceMerge(1) against 60 executed index-and-policy runs and wrote the one-test repro on Lucene's own 9.0.0 test index.
The method in full100 words
- First signal. A pass that looked for upgrade problems ran the MIGRATE.md example on the stock main
645d4fe14a20jars against indexes written by the 9.12.3 jar (2026-10-29): the single-segment index stayedLucene912/ 9.12.3, a three-segment index was rewritten. - Hunt. A decision model of "forceMerge(1) leaves every segment at the latest version", written from the code, was checked against 60 executed (index state x merge policy) runs: the model matched 60 of 60, the guide's claim 50 of 60. The one-test repro uses Lucene's own
moreterms.9.0.0fixture. - Tier. Non-formal: an executed check of a documentation claim, with a model-based cross-check.
No prior report found: GitHub issues and PRs (with the JIRA archive), commits, the dev@, java-user@, issues@ and commits@ lists, downstream projects and the web were searched on 2026-10-29 and re-checked on 2026-11-01. Older issues describe the intended no-op (LUCENE-3082, LUCENE-5526, LUCENE-5481), not this text.
Searches3 searches
Related, not reports: #4155 / LUCENE-3082, #6589 / LUCENE-5526, #6544 / LUCENE-5481, #9052 / LUCENE-8004 and #9311 / LUCENE-8264 describe the intended no-op and why IndexUpgrader exists; #13797, #15012, #16741 and #16756 wrote or edited the 11.0 text without discussing single segments; #7002 / LUCENE-5940 (2014) has a similarly loose phrase, "a forceMerge(), or use of IndexUpgrader"; open #16922 touches only the shouldUpgradeSegment javadoc; elastic/elasticsearch#5243 (a 2014 enhancement request by a developer of that project, "Force single-segment merges") and a 2022 Elastic forum thread show the same behaviour downstream.
Open PRs that edit MIGRATE.md (#16805, #16806 and earlier ones) touch other sections.
| Where | When | How | Found |
|---|---|---|---|
| Scripted searches | 2026-10-29 | 52 queries over GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web | only the background items above |
| GitHub, manual | 2026-10-29 | 29 queries; every comment and review of #15012, #16741, #16756 and #13797; the files of all 415 open PRs; git log -S on both sentences | no report of the text |
| Re-check | 2026-11-01 | issue and PR search for the two sentences and for forceMerge, MIGRATE and upgrade since 2026-10-29; the files of the 31 open PRs updated since then; the text on main and branch_11x | no report; #16805 and #16806 add other MIGRATE.md sections, #16949 edits another javadoc; text unchanged |
Text added to main by #15012 (2025-09-18), already wrong then for single-segment 10.x indexes; #16741 (2026-10-15) widened it to 9.x indexes. No release has it; main and branch_11x do, so it would ship in 11.0.0. The forceMerge no-op itself is old and intended.
Versions tested4 rows
- Written on the #15012 branch (
ce08448cf5, 2025-07-31); merged as2d8f0cb5289(#15012, Mark Robert Miller, 2025-09-18).git blameattributes lines 153 and 177-180 to it. The example then named only "an index created with Lucene 10.x", and the commit changed no merge code. - Widened by
8476457b8752(#16741, Luca Cavanna, 2026-10-15):MIN_SUPPORTED_MAJOR9 and the comment "9.x or 10.x" (line 172), which brings the repro's 9.0.0 index into scope. Before that,120b3bc18da(#16756, Luca Cavanna, 2026-10-07) reworded only that comment line.999c1a45b4f(#15650, 2026-02-01) only reformatted. - Upstream now: main
f815e679bf6c(12.0.0-SNAPSHOT) andbranch_11xda3ea39fa597carry the block unchanged, 14 lines lower after #16857. - The behaviour goes back to LUCENE-3082 (2011), LUCENE-5481 (2014) and the
TieredMergePolicycondition, added in its current form by LUCENE-7976 (2519025fdaf, 2018; reformatted in 2020).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | fails, expected:<11.0.0> but was:<9.0.0>, with -ea and -da |
upstream main f815e679bf6c, branch_11x da3ea39fa597 | read | the same text, 14 lines lower; TieredMergePolicy unchanged (read, not run) |
10.6.0, 10.5.x, every release, branch_10x | no such text | not affected: the guide makes no such claim |
| 9.12.3, 10.0.0, 10.3.0, 10.3.1, 10.5.2 | behaviour only | the same forceMerge(1) also leaves the 9.0.0 segment unchanged: old, intended behaviour |
An independent reproducer, a reviewer from another model family (OpenAI GPT-5.6) under lenses, a devil's-advocate check, an adjudication and an audit. Every completed review found it real; the adjudication confirmed it with high confidence as a Low documentation defect. The brief re-ran the test and validated the fix.
Checks and verdicts256 words
The cross-model reviews ran on an older checkout where MIGRATE.md and the merge-policy and IndexWriter sources are identical to 645d4fe14a20. The intended-lens review could only read; a separate re-run with the fixture path fails with -ea and -da.
| Check | Verdict | What it found |
|---|---|---|
| Finder | reproduced (doc) | Model checked against 60 executed runs (60/60; the claim 50/60); the one-test repro fails on main with -ea and -da, 20 of 20 seeds; the corrected example passes. |
| Refute lens (OpenAI GPT-5.6) | real, high | Own one-document 10.5.2 index and own test: "expected 11.0.0 but was 10.5.2" with three seeds and -da; a two-segment control and the corrected sequence pass. |
| Reproducer | real (doc, Low) | Own 9.12.3 fixture and test fail on main with -ea and -da, 10 of 10 seeds; controls pass; a 10.5.2 single segment stays 10.5.2. |
| Intended lens (OpenAI GPT-5.6) | real, medium | The intended argument is conclusive for the code but "does not dispose of the reported documentation defect" (reading only). |
| Devil's advocate | real (doc, Low), high | Weighed the contract, the "sketch" reading and the authors' intent; re-ran on main with -ea and -da: fail. |
| Adjudication and audit | confirmed (high), Low | Re-ran with new seeds, -ea and -da: fail; the one-test repro fails and its fixed twin passes. |
| Brief | verified; fix validated | Minimal test through Gradle on main fails with assertions on and off; the diff applies (also to upstream main); the corrected example passes; 362 tests of the closest suites pass; the guide renders. |
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 304 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,312 words
- Registry title
- MIGRATE.md's 11.0 'Upgrade Example' says forceMerge(1) 'Rewrites all segments to latest format', but with the default merge policy it leaves an index that already has one segment and no deletions unchanged in its 9.x or 10.x format (documentation defect, unreleased; the code is correct)
- Area
- index / forceMerge and index upgrade (lucene/MIGRATE.md 11.0 'Relaxed Index Upgrade Policy': Scenario 1 and 'Upgrade Example')
- Priority
- Low, rank 1263: The unreleased MIGRATE.md forceMerge(1) step quietly does nothing on single-segment indexes with no deletions and can later end in a loud IndexFormatTooOldException with data intact.
- Impact class
- minor
- Impact note
- Documentation only, unreleased (main and branch_11x, the future 11.0): a user who upgrades a 9.x or 10.x index by following the guide's optional 'upgrade segment formats' step with the default IndexWriterConfig gets a call that returns normally and rewrites nothing when the index already has a single segment without deletions, the usual shape of a static, force-merged index; the index stays fully readable and how long it stays openable is unaffected, but for 9.x and 10.0-10.3 indexes the left-over segment keeps needing lucene-backward-codecs (opening it without that jar fails loudly with IndexFormatTooOldException, executed for a 9.0.0 index), and 10.4/10.5 segments keep their older version stamp and sub-format versions.
- Summary
- Lucene main's MIGRATE.md tells users who open a Lucene 9.x or 10.x index to upgrade its segments by opening an IndexWriter and calling forceMerge(1), with the comment 'Rewrites all segments to latest format' (line 179), and line 153 offers forceMerge() as a way 'to upgrade segment formats immediately'. With the default merge policy, forceMerge(1) does nothing on an index that already has one segment and no deletions: TieredMergePolicy reports it as already merged, so on Lucene's own single-segment 9.0.0 test index the segment is still version 9.0.0 with codec Lucene90 afterwards. The code behaves as designed (forceMerge only promises a segment count, and UpgradeIndexMergePolicy and IndexUpgrader exist for exactly this case); the guide should wrap the merge policy in UpgradeIndexMergePolicy or point to IndexUpgrader.
- Introduced
- 2d8f0cb5289b5b948a73449f06a992a16f44c8aa, apache/lucene#15012 'Relax Lucene Index Upgrade Policy to Allow Safe Upgrades Across Multiple Major Versions' (markrmiller; squash merge, author and commit date 2025-09-18; the text was first written on the PR's only branch commit ce08448cf5, author date 2025-07-31) [read: git log -S and git blame in the full-history clone attribute MIGRATE.md:153 and :177-180 to it]. At that commit the example named only 'an index created with Lucene 10.x', and the commit changed no merge code, so it was already wrong for single-segment 10.x indexes. 8476457b8752 (#16741, javanna, 2026-10-15) lowered MIN_SUPPORTED_MAJOR to 9 and widened the example's comment to '9.x or 10.x', which extends the defect to 9.x indexes (the case the minimal repro uses). 999c1a45b4f (#15650, 2026-02-01) only reformatted the markdown. Not a code regression: the forceMerge no-op is old and intended (LUCENE-3082 2011, LUCENE-5481 2014, the current TieredMergePolicy condition from LUCENE-7976 2018) (2025-09-18 (merged; written 2025-07-31 on the PR branch); widened to 9.x indexes on 2026-10-15)
- First affected version
- unreleased (main and branch_11x only; would first ship in 11.0.0)
- Affected versions
- main from 2d8f0cb5289 (2025-09-18) through 645d4fe14a20 [exec: the minimal repro fails on 645d4fe with -ea and -da; read: the text is present]; upstream main f815e679bf6c and branch_11x da3ea39fa597 (2026-11-01) carry the same text, 14 lines lower [read]; the 9.x-index case of the repro only from 8476457b8752 (#16741, 2026-10-15), before which main did not open 9.x-created indexes at all [read: Version.java, not executed]. Not affected: every release, branch_10x (657014b3c57), branch_10_6 (4ae946e5a9b, 10.6.0 RC1) and branch_10_5 [read: the text is absent; git tag --contains 2d8f0cb5289 lists no release; checked 2026-10-29]. The forceMerge behaviour underneath is the same in 9.12.3, 10.0.0, 10.3.0, 10.3.1 and 10.5.2 [exec: the same test leaves the 9.0.0 segment unchanged there], but those releases make no such claim, so they are not affected; all runs used JDK 25 on Linux aarch64, which does not matter for this merge-selection logic
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal
- Upstream and prior reports
- none found (searched 2026-10-29: scripted searches over GitHub issues, PRs and commits (19 queries), Elasticsearch, OpenSearch and Solr (2), the dev@, java-user@, issues@ and commits@ lists (20), the JIRA archive (4) and the web (7); 29 more manual GitHub queries incl. everything created or updated since 2026-10-26; all comments and reviews of #15012, #16741, #16756 and #13797; the files of all 415 open PRs; git log -S in the history clone; the finder's, the independent reproduction's and the intended-behaviour check's searches found nothing either; recheck 2026-10-29: upstream main still 645d4fe14a2 with lines 153 and 179 unchanged, no issue or PR updated since 2026-10-27 reports the text, and none of the 39 open PRs updated since then edits it). Nearest items, not the same bug: #4155 / LUCENE-3082 (2011, the reason IndexUpgrader and UpgradeIndexMergePolicy exist: 'optimizing a one-segment index is a no-op without the special merge-policy'), #6589 / LUCENE-5526, #6544 / LUCENE-5481, #9052 / LUCENE-8004 and #9311 / LUCENE-8264 (the intended forceMerge behaviour); #13797, #15012, #16741 and #16756 (wrote or edited the 11.0 text without discussing single-segment indexes); open #16922 (UpgradeIndexMergePolicy.shouldUpgradeSegment javadoc only); elastic/elasticsearch#5243 and a 2022 Elastic forum thread (the same underlying behaviour in a downstream product). Not fixed on upstream main (checked 2026-10-29).
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails with assertions on and off, standalone and through Gradle (./gradlew :lucene:backward-codecs:test); the doc fix applied to645d4fe14a20: the same test with the corrected example's one line passes with assertions on and off,TestIndexUpgradeBackwardsCompatibility,TestUpgradeIndexMergePolicyandTestTieredMergePolicypass (362 tests), and:lucene:documentation:markdownToHtmlrenders the corrected text. Releases 9.12.3, 10.0.0, 10.3.0, 10.3.1 and 10.5.2 were run for the behaviour only (the text is absent there). Upstream mainf815e679bf6candbranch_11xda3ea39fa597(2026-11-01) were read, not run: the text is unchanged and the diff applies.
- Finding record
lucene/MIGRATE.md:153 ('Optional: Call `forceMerge()` or use `UpgradeIndexMergePolicy` to upgrade segment formats immediately'), :169-183 ('Upgrade Example'; :179 'writer.forceMerge(1); // Rewrites all segments to latest format')lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:833-847 (findForcedMerges: maxSegmentCount == 1, one eligible segment, no deletions and an original segment -> 'already merged', return null)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2187-2189 (forceMerge promises only '<= maxNumSegments', merges chosen by the MergePolicy), :2251-2255 (every current segment is marked as an original segment to merge)lucene/core/src/java/org/apache/lucene/index/UpgradeIndexMergePolicy.java:26-31 (the documented way to rewrite old segments on forceMerge); lucene/test-framework/.../BaseMergePolicyTestCase.java:79 (testForceMergeNotNeeded)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/MIGRATE.md:147-183 (Scenario 1 line 153; Upgrade Example 169-183, line 179)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2187-2193, 2251-2255a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:143a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:808-848a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/UpgradeIndexMergePolicy.java:26-31, 68-70, 80-139; IndexUpgrader.java:30-53, 166-196a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:746-758
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2191-2193 (static indexes)a Lucene main checkout (645d4fe14a20) lucene/MIGRATE.md:154, 162-167 (lifetime depends on MIN_SUPPORTED_MAJOR and the creation version)
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/MIGRATE.md:152-153, 179a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseMergePolicyTestCase.java:79 (testForceMergeNotNeeded)a Lucene main checkout (645d4fe14a20) lucene/backward-codecs/src/test/org/apache/lucene/backward_index/TestIndexUpgradeBackwardsCompatibility.java:69-88
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/UpgradeIndexMergePolicy.java:36-48 (javadoc example; reorder warning); IndexUpgrader.java:43-46, 166-181a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/UpgradeIndexMergePolicy.java:50 (@lucene.experimental); IndexUpgrader.java:36 (command line), :180
- TestMigrateUpgradeExample.java (one test, public API):
repro/LFV-1030/TestMigrateUpgradeExample.java(43 lines)
Four steps on Lucene's own one-segment 9.0.0 test index: the guide's example, what forceMerge(1) and TieredMergePolicy do (the code at 645d4fe14a20), expected vs actual, and the fix with UpgradeIndexMergePolicy. The PNG is the summary frame.
assets/LFV-1030/bug.png(304 KB)
