All findings · Lucene finding · LFV-1030 · migrate11-upgrade-example-forcemerge-keeps-single-old-segment

MIGRATE.md's 11.0 upgrade example says forceMerge(1) rewrites all segments, but it leaves a single-segment index in its old format

MIGRATE.md's 11.0 upgrade example says writer.forceMerge(1) "Rewrites all segments to latest format", but the default merge policy sees an index with one segment and no deletions as already merged, so Lucene's own 9.0.0 test index stays at 9.0.0. The code is right; the guide should use UpgradeIndexMergePolicy.

Summary

Priority
Low, rank 1263The 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
minor
Since
unreleased (text on main since 2025-09-18; would first ship in 11.0.0)introduced 2025-09-18 · about 1 year
Affected
main and branch_11x (documentation only; no release)
Status
documented
Found by
an executed check of the MIGRATE.md example (non-formal)

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

Segments, versions and forceMerge

  1. 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.
  2. 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.
  3. 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.
Before and after: an index with one segment, _0, version 9.0.0, codec Lucene90, 50 documents, no deletions. Path 1, the MIGRATE.md example with a default IndexWriterConfig: forceMerge(1), TieredMergePolicy says already merged, no merge runs, the segment stays 9.0.0 / Lucene90 and still needs lucene-backward-codecs. Path 2, the same call with the merge policy wrapped in UpgradeIndexMergePolicy: the old segment is rewritten to a new segment at version 11.0.0, codec Lucene104, 50 documents.
A one-segment 9.0.0 index through the guide's 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.

The finding, step by step

  1. 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 an IndexWriter with a default IndexWriterConfig and call forceMerge(1).
  2. You expect what the comment promises, "Rewrites all segments to latest format": one segment at version 11.0.0, codec Lucene104.
  3. Instead nothing happens. forceMerge(1) only promises at most one segment, and the default TieredMergePolicy treats one segment without deletions as "already merged" and picks no merge. The call returns normally; the segment is still 9.0.0 with codec Lucene90.
  4. So the index still needs lucene-backward-codecs: opened without it, it fails with IndexFormatTooOldException ("Could not load codec 'Lucene90'"). With the policy wrapped in UpgradeIndexMergePolicy, the tool made for this, the same call rewrites it to 11.0.0, and it opens with lucene-core alone.
  5. 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.

Who is affected

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.

The text (main 645d4fe14a20, lucene/MIGRATE.md)

  • Line 153 (Scenario 1): "Optional: Call forceMerge() or use UpgradeIndexMergePolicy to 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). config is not defined in the snippet.
  • On today's upstream main and branch_11x the same block sits 14 lines lower (lines 167 and 193), unchanged.

The code (correct and intended)

  • IndexWriter.forceMerge javadoc (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).
  • forceMerge marks every current segment as an original segment to merge (IndexWriter.java:2251-2255). new IndexWriterConfig() uses TieredMergePolicy (LiveIndexWriterConfig.java:143).
  • TieredMergePolicy.findForcedMerges (TieredMergePolicy.java:834-847): with no deletions, maxSegmentCount == 1, one eligible segment and that segment in segmentsToMerge, it logs "already merged" and returns null. 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 not Version.LATEST (:68-70) and merges the ones its delegate declines itself (:117-137). IndexUpgrader.upgrade() uses it (IndexUpgrader.java:180, :196).
  • The LogMergePolicy variants also skip a lone segment without deletions when its compound-file flag already matches (MergePolicy.isMerged, MergePolicy.java:750-758).

The executed matrix

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 InfoStream shows "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" gets IndexFormatTooOldException ... 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 use Lucene104 and 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.

Minimal repro

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.

TestMigrateUpgradeExample.java (one test, public API)43 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.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());
    }
  }
}
Command
# $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 _0 keeps version 9.0.0 and codec Lucene90, with assertions on and off.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); upstream main and branch_11x carry the same text (read)
jdkOpenJDK 25.0.3
osLinux aarch64 (merge-selection logic; the platform cannot change the result)

Observed output

# ./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

Why it looks unintended

  • 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 to UpgradeIndexMergePolicy.
  • The no-op is deliberate and old: LUCENE-3082 added UpgradeIndexMergePolicy in 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 "Call forceMerge() or use UpgradeIndexMergePolicy" 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.testForceMergeNotNeeded fails 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.

Suggested fix

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

  • UpgradeIndexMergePolicy rewrites 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 SoftDeletesRetentionMergePolicy inside, its leftover merge bypasses retention (LFV-617); at most a caveat.
  • UpgradeIndexMergePolicy is marked @lucene.experimental; it has existed since 2011 and is what IndexUpgrader uses.
  • The snippet stays a sketch: it still leaves config to the reader, and real code also needs import org.apache.lucene.index.UpgradeIndexMergePolicy. Whatever config is must match the index (for example its index sort or parent field), or new IndexWriter(dir, config) fails before forceMerge runs; that holds for the current example too.

What the checks validated

  • The diff applies cleanly to 645d4fe14a20 and, 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, TestUpgradeIndexMergePolicy and TestTieredMergePolicy pass (340 + 5 + 17 tests, 0 failures); a sanity check, since the change is documentation only.
  • ./gradlew :lucene:documentation:markdownToHtml succeeds and the rendered page shows the corrected text.

Point to IndexUpgrader only

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.

Fix the wording only

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.

Make forceMerge rewrite old-format segments (not recommended)

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 note

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").

How it was found

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 645d4fe14a20 jars against indexes written by the 9.12.3 jar (2026-10-29): the single-segment index stayed Lucene912 / 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.0 fixture.
  • Tier. Non-formal: an executed check of a documentation claim, with a model-based cross-check.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
Scripted searches2026-10-2952 queries over GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the webonly the background items above
GitHub, manual2026-10-2929 queries; every comment and review of #15012, #16741, #16756 and #13797; the files of all 415 open PRs; git log -S on both sentencesno report of the text
Re-check2026-11-01issue 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_11xno report; #16805 and #16806 add other MIGRATE.md sections, #16949 edits another javadoc; text unchanged

History

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 as 2d8f0cb5289 (#15012, Mark Robert Miller, 2025-09-18). git blame attributes 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_MAJOR 9 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) and branch_11x da3ea39fa597 carry the block unchanged, 14 lines lower after #16857.
  • The behaviour goes back to LUCENE-3082 (2011), LUCENE-5481 (2014) and the TieredMergePolicy condition, added in its current form by LUCENE-7976 (2519025fdaf, 2018; reformatted in 2020).
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedfails, expected:<11.0.0> but was:<9.0.0>, with -ea and -da
upstream main f815e679bf6c, branch_11x da3ea39fa597 readthe same text, 14 lines lower; TieredMergePolicy unchanged (read, not run)
10.6.0, 10.5.x, every release, branch_10x no such textnot affected: the guide makes no such claim
9.12.3, 10.0.0, 10.3.0, 10.3.1, 10.5.2 behaviour onlythe same forceMerge(1) also leaves the 9.0.0 segment unchanged: old, intended behaviour

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat 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, highOwn 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, mediumThe intended argument is conclusive for the code but "does not dispose of the reported documentation defect" (reading only).
Devil's advocate real (doc, Low), highWeighed the contract, the "sketch" reading and the authors' intent; re-ran on main with -ea and -da: fail.
Adjudication and audit confirmed (high), LowRe-ran with new seeds, -ea and -da: fail; the one-test repro fails and its fixed twin passes.
Brief verified; fix validatedMinimal 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.

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,312 words

Finding record

LFV-1030, snapshot of 2026-11-06

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 to 645d4fe14a20: the same test with the corrected example's one line passes with assertions on and off, TestIndexUpgradeBackwardsCompatibility, TestUpgradeIndexMergePolicy and TestTieredMergePolicy pass (362 tests), and :lucene:documentation:markdownToHtml renders 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 main f815e679bf6c and branch_11x da3ea39fa597 (2026-11-01) were read, not run: the text is unchanged and the diff applies.

Lucene and upstream sources

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-2255
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:143
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:808-848
  • a 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-196
  • a 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, 179
  • a 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-181
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/UpgradeIndexMergePolicy.java:50 (@lucene.experimental); IndexUpgrader.java:36 (command line), :180

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

Repro files

About the diagrams

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.

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.