All findings · Lucene finding · LFV-575 · point-in-time-merge-wrapper-skips-mergeinit

Merges on commit and getReader never call the merge policy's OneMerge#mergeInit

When commit() or getReader merges small segments (default since 9.3.0), IndexWriter wraps the policy's OneMerge and forwards mergeFinished but not mergeInit. A subclass that overrides mergeInit is told a merge finished, never that it started (in the repro: mergeInit 0 calls, mergeFinished 1); stock Lucene is unaffected.

Summary

Priority
Low, rank 1165The merge-on-commit wrapper skips the OneMerge mergeInit hook, changing nothing on stock Lucene and mattering only for custom OneMerge subclasses, a lifecycle-hook gap like LFV-108.
Impact
minor
Since
8.6.0 (2020, with merge on commit); on by default since 9.3.0introduced 2020-06-27 · about 6 years
Affected
every release from 8.6.0 to 10.6.0, and main
Status
documented
Found by
a bytecode scan of which hooks each OneMerge wrapper forwards, then a JUnit repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Background: merge hooks and merges on commit

  1. A merge policy describes each merge as a MergePolicy.OneMerge. A subclass can override public hooks that IndexWriter calls, among them mergeInit when the merge starts and mergeFinished when it is done.
  2. Since 8.6.0, commit() (and since 8.7.0 getReader) can merge small segments before it returns, using MergePolicy#findFullFlushMerges; by default that method returns the policy's ordinary findMerges merges below a size limit. Since 9.3.0 this is on by default: wait up to 500 ms.
  3. For these merges IndexWriter does not run the policy's OneMerge itself: it wraps it in its own OneMerge, which swaps the merged segment into the commit or reader being opened.
Diagram of the bug: two commits of one document each; the second commit merges the 2 segments into 1. IndexWriter wraps the merge policy's OneMerge in its own wrapper OneMerge and runs the wrapper. IndexWriter calls mergeInit on the wrapper, which does not pass it on, so the policy's OneMerge counts mergeInit = 0. When the merge is done IndexWriter calls mergeFinished on the wrapper, which passes it on, so the policy's OneMerge counts mergeFinished = 1 (success = true). The commit point has 1 segment. The same OneMerge from findMerges or forceMerge counts 1 and 1.
The policy's OneMerge inside IndexWriter's merge-on-commit wrapper: mergeInit stops at the wrapper, mergeFinished goes through. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Write a merge policy whose OneMerge subclass overrides the public hooks mergeInit ("the merge started") and mergeFinished ("the merge is done"), for example to time each merge. Keep the default IndexWriterConfig: since 9.3.0 commit() waits up to 500 ms for small merges the policy returns.
  2. Add one document and commit, then add another and commit. The second commit merges the 2 one-document segments into 1 before it returns.
  3. You expect the policy's OneMerge to get mergeInit once and mergeFinished once, as the same class does when it comes from findMerges or forceMerge.
  4. Instead it gets mergeFinished(success=true) once and mergeInit 0 times. For this merge IndexWriter runs its own wrapper OneMerge, which passes mergeFinished and four other hooks on to the policy's merge, but not mergeInit.
  5. The merge itself is fine: the commit has 1 segment. A merge on DirectoryReader.open(writer) behaves the same. Every release from 8.6.0 to 10.6.0 and main is affected; nothing in Lucene overrides mergeInit.

Who is affected

Only code with its own OneMerge subclass that overrides mergeInit, for merges inside commit(), prepareCommit() or getReader (on by default since 9.3.0, reached even through a plain findMerges customisation of LogMergePolicy or TieredMergePolicy). It then misses mergeInit on every such merge, deterministically. Nothing in Lucene overrides mergeInit, so stock Lucene, merged segments and search results are unaffected.

Mechanism, terms and numbers718 words

Since 8.6.0, commit() and, since 8.7.0, getReader can merge small segments before they return, using the merges the policy returns from findFullFlushMerges; since 9.3.0 this is on by default with a 500 ms wait. For these merges IndexWriter wraps each policy OneMerge in an anonymous OneMerge (IndexWriter.preparePointInTimeMerge) and runs only the wrapper. The wrapper forwards mergeFinished, onMergeComplete, wrapForMerge, reorder and setMergeInfo to the policy's merge, but has no mergeInit override. So a policy's OneMerge that overrides mergeInit gets mergeFinished with no mergeInit before it on every such merge: mergeFinished(true, false) for a merge that ran and was committed, as in the repro; the same class from findMerges or forceMerge gets both. A policy does not have to override findFullFlushMerges: the default returns its own findMerges merges below maxFullFlushMergeSize(), a limit that LogMergePolicy and TieredMergePolicy set (the base MergePolicy returns 0). Stock Lucene shows no effect, because the skipped default only records the merge thread and nothing in Lucene overrides mergeInit. Main and every release from 8.6.0 to 10.6.0 fail the repro (ported for 8.6.0-9.0.0, 9.1 and 9.2); a 6-line forward fixes it. Low priority.

Terms

  • OneMerge hooks: MergePolicy.OneMerge is public (@lucene.experimental). mergeInit() is "Called by IndexWriter after the merge started and from the thread that will be executing the merge"; mergeFinished(success, segmentDropped) is "Called by IndexWriter after the merge is done and all readers have been closed" (MergePolicy.java:268-282).
  • Merge on commit / getReader ("full-flush merges"): findFullFlushMerges (MergePolicy.java:664-714) and IndexWriterConfig#setMaxFullFlushMergeWaitMillis, default DEFAULT_MAX_FULL_FLUSH_MERGE_WAIT_MILLIS = 500 (IndexWriterConfig.java:108).

The bug

  1. commit() calls preparePointInTimeMerge(..., MergeTrigger.COMMIT, ...) when the wait is above 0 (IndexWriter.java:3858-3864); getReader calls it with GET_READER (:634-638).
  2. preparePointInTimeMerge (:3950-4110) runs the configured policy through a OneMergeWrappingMergePolicy whose function replaces every merge with new MergePolicy.OneMerge(toWrap) { ... } (:3959-3964). IndexWriter registers and runs only that wrapper.
  3. The wrapper forwards toWrap.mergeFinished (:4027), toWrap.onMergeComplete (:4043), toWrap.wrapForMerge and toWrap.reorder (both commented // must delegate, :4061, :4067) and toWrap.setMergeInfo (:4073). It has no mergeInit override.
  4. IndexWriter.merge -> mergeInit(merge) (:5075) -> _mergeInit -> merge.mergeInit() on the wrapper (:5289). The base method only records the merge thread in the wrapper's own progress object (MergePolicy.java:272-274; the wrapping constructor creates a fresh one, :259-266).
  5. When the merge closes, OneMerge.close calls the wrapper's mergeFinished (MergePolicy.java:285-293), which passes its arguments on as toWrap.mergeFinished(committed, segmentDropped) (:4027); for the repro's merge that is (true, false).

The numbers (the repro)

Two commits of one empty document each, with a policy whose findFullFlushMerges returns one counting OneMerge over both segments. After the second commit the commit point has 1 segment and the policy's OneMerge has mergeFinished = 1 and mergeInit = 0. The same counting class handed out by findMerges (an explicit maybeMerge()) or findForcedMerges gets 1 and 1.

Variants that behave the same

  • A merge on DirectoryReader.open(writer) (GET_READER), and a merge on commit with the default ConcurrentMergeScheduler: events [finished:true:false] instead of [init, finished:true:false], 10 of 10 iterations each.
  • A policy that overrides only findMerges on a stock LogDocMergePolicy, default settings: the default findFullFlushMerges returns the same objects, which get init=0 finished=1. (The default keeps merges whose segments are below maxFullFlushMergeSize(): minMergeSize in LogMergePolicy, floorSegmentBytes in TieredMergePolicy, 0 in the base MergePolicy, MergePolicy.java:730-736.)

Not claimed

IndexWriter does not always pair the two hooks: on rollback, merges that never started get mergeFinished(false, false) without mergeInit (IndexWriter.java:2821-2834, :5133-5136, :5394-5403), which the javadoc allows. Only the success case on this path is the bug. The repro's merge succeeds and keeps its segment, so it gets mergeFinished(true, false); a merge that fails or whose segment is dropped would get success=false or segmentDropped=true, also without mergeInit (code reading). An application can create the same asymmetry on any path with its own code, by installing a OneMergeWrappingMergePolicy whose wrapper forwards mergeFinished but not mergeInit; IndexWriter's other paths run the policy's own merge. By code reading only (untested): because the wrapper has its own progress object, the policy's OneMerge also never sees isAborted() == true, and IndexWriter sets the merge byte counts on the wrapper.

Exposure and likelihood210 words
  • Who is exposed. Applications or libraries whose merge policy hands out a OneMerge subclass that overrides mergeInit, for merges that run inside commit(), prepareCommit() or getReader / DirectoryReader.open(writer). On by default since 9.3.0; opt-in (a full-flush wait above 0) in 8.6.0-9.2.x.
  • What they see. mergeFinished with no mergeInit before it, on every such merge; for a merge that succeeds and keeps its segment, as in the repro, that is mergeFinished(true, false). Anything paired across the two hooks gets out of step: a timer never started, a per-merge resource released without being taken, a running-merge counter that goes negative. What follows is defined by that code, not by Lucene.
  • Scale. None needed: two one-document segments, every time, assertions on and off.
  • Stock Lucene. No observable effect. The skipped base method only writes the private field OneMergeProgress.owner, "for sanity-check purposes only" (MergePolicy.java:106-111), which no code reads except an assert in its own setter, and no class in Lucene overrides mergeInit (at 645d4fe14a20). Merged content, deletes, search results, aborting the running merge and the commit wait bound are unaffected (code reading).
  • How likely. Rare: GitHub code searches found no mergeInit override in Elasticsearch, OpenSearch or Solr (not exhaustive), and nobody has reported it since 8.6.0.

Minimal repro

One JUnit test on the public API: a FilterMergePolicy over LogDocMergePolicy whose findFullFlushMerges returns one counting OneMerge, the default IndexWriterConfig with SerialMergeScheduler, and two commits of one empty document. It first checks that the merge on commit ran, then counts mergeInit.

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

import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestMergeOnCommitCallsMergeInit extends LuceneTestCase {

  public void testMergeOnCommitCallsMergeInit() throws Exception {
    int[] mergeInit = new int[1];
    int[] mergeFinished = new int[1];
    MergePolicy mp =
        new FilterMergePolicy(new LogDocMergePolicy()) {
          @Override
          public MergeSpecification findFullFlushMerges(
              MergeTrigger trigger, SegmentInfos infos, MergeContext context) {
            if (infos.size() < 2) {
              return null;
            }
            MergeSpecification spec = new MergeSpecification();
            spec.add(
                new OneMerge(infos.asList()) {
                  @Override
                  public void mergeInit() throws IOException {
                    mergeInit[0]++;
                    super.mergeInit();
                  }

                  @Override
                  public void mergeFinished(boolean success, boolean segmentDropped)
                      throws IOException {
                    mergeFinished[0]++;
                    super.mergeFinished(success, segmentDropped);
                  }
                });
            return spec;
          }
        };
    IndexWriterConfig iwc =
        new IndexWriterConfig().setMergePolicy(mp).setMergeScheduler(new SerialMergeScheduler());
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      w.addDocument(new Document());
      w.commit();
      w.addDocument(new Document());
      w.commit(); // merges the two segments on commit
      assertEquals(1, SegmentInfos.readLatestCommit(dir).size());
      assertEquals(1, mergeFinished[0]);
      assertEquals("mergeInit calls before mergeFinished", 1, mergeInit[0]);
    }
  }
}
Command
cp $REPRO/TestMergeOnCommitCallsMergeInit.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestMergeOnCommitCallsMergeInit -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false
Expected
The commit point has 1 segment and the policy's OneMerge got mergeInit once and mergeFinished once.
Observed
1 segment and mergeFinished once, but mergeInit 0 times: mergeInit calls before mergeFinished expected:<1> but was:<0> (assertions on and off).

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

More files, environment, full output253 words

Public API only (FilterMergePolicy, LogDocMergePolicy, the public OneMerge(List) constructor and hooks, MergeSpecification, SerialMergeScheduler, SegmentInfos.readLatestCommit, ByteBuffersDirectory; the test framework's LuceneTestCase). It sits in org.apache.lucene.index next to the core tests but uses no package-private API. The first two assertions (1 segment, mergeFinished once) pass on stock code and prove that the merge on commit ran and was committed; the third fails. With SerialMergeScheduler, a non-zero wait always waits for the full-flush merge to finish, whatever its value (IndexWriterConfig.java:495-497): that makes the test deterministic, and any wait above 0 turns the path on. The tie-break ran the same path with the default ConcurrentMergeScheduler (wait raised to 60 s): same result, 10 of 10.

The same test is the suggested regression test. Before 9.3.0 merge on commit is off by default (wait 0): on 9.1.x and 9.2.x add setMaxFullFlushMergeWaitMillis(500); on 8.6.0-9.0.0 also use the old test-framework package (org.apache.lucene.util.LuceneTestCase), and on 8.6.x the setter is setMaxCommitMergeWaitMillis (from 8.7.0 on it is setMaxFullFlushMergeWaitMillis).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails with assertions on and off; with the fix: passes
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (environment lines cut)
TestMergeOnCommitCallsMergeInit > testMergeOnCommitCallsMergeInit FAILED
    java.lang.AssertionError: mergeInit calls before mergeFinished expected:<1> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:24681E7CA4B8BF07]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:647)
        at org.apache.lucene.index.TestMergeOnCommitCallsMergeInit.testMergeOnCommitCallsMergeInit(TestMergeOnCommitCallsMergeInit.java:67)

1 test completed, 1 failed

# -Ptests.asserts=false: the same failure and message
# with the fix: 1 test, 0 failures (assertions on and off)

Why it looks unintended

  • mergeInit is public, documented as "Called by IndexWriter after the merge started", with no exception for merges on commit.
  • The wrapper forwards the paired mergeFinished and four other hooks but not mergeInit; no other IndexWriter path reports a successful finish without it.
  • Upstream added the missing setMergeInfo forward to this same wrapper in #12622.
The full argument201 words
  • Contract. mergeInit is public and non-final, documented as "Called by IndexWriter after the merge started and from the thread that will be executing the merge" (MergePolicy.java:268-271). No javadoc, CHANGES or MIGRATE entry exempts full-flush merges.
  • Meant to be used. LUCENE-7700 refactored merge throughput control "into a more accessible set of public methods (see MergePolicy.OneMergeProgress, MergeScheduler.wrapForMerge and OneMerge.mergeInit)" (lucene/CHANGES.txt:8405-8409).
  • The wrapper treats the policy's merge as the real one. It forwards mergeFinished, onMergeComplete, setMergeInfo, wrapForMerge and reorder (IndexWriter.java:4027-4073). IndexWriterConfig.java:482-487 says these merges "will still run to completion independent of the commit or getReader call, like natural segment merges".
  • Precedent on this wrapper. #12622 (9.9.0) added the reorder and setMergeInfo forwards. Asked in review "did we have a test that realized that merge.info is missing?", the author answered "Yes, some of the new tests failed ... Let me add dedicated tests for setMergeInfo."; TestIndexWriterMergePolicy.testSetDiagnostics wraps both findMerges and findFullFlushMerges merges.
  • No documented rationale found. The wrapper never had mergeInit (first version 7f352a96659, #1617); no commit in the history contains toWrap.mergeInit; the design threads that were read never discuss it. That is an absence of evidence, not proof that the omission was unintended.
The devil's-advocate check250 words

The strongest case, built by verifiers and the adjudication: IndexWriter executes the wrapper and does call mergeInit on it, which satisfies "called after the merge started"; the base method touches only the wrapper's own progress object. Other in-tree wrappers forward even less (SoftDeletesRetentionMergePolicy only wrapForMerge, BPReorderingMergePolicy neither mergeInit nor mergeFinished), and open PR #16571 adds a FilterOneMerge that forwards neither, with the note "Wrapping creates a new OneMerge, so any behaviour not forwarded is lost." IndexWriter already sends mergeFinished(false, false) without mergeInit on rollback, OneMerge is experimental, and nobody overrides mergeInit.

Why it does not settle it: those wrappers forward no lifecycle hook at all, so their inner merge sees a consistent, empty lifecycle. This wrapper forwards mergeFinished, onMergeComplete and setMergeInfo, so the policy's merge hears that it finished successfully without hearing that it started, which happens on no other IndexWriter path (an application gets it elsewhere only by installing such a wrapper itself). The user never asked for this wrapper: IndexWriter puts it around every merge the policy returns from findFullFlushMerges, so avoiding it means returning no full-flush merges or turning merge on commit off. The rollback case is a merge that never started, which the javadoc allows. The javadoc does not say whether the hooks reach a merge that IndexWriter wraps, so a maintainer can reasonably prefer to document mergeInit as plumbing of the executing merge instead of forwarding it; that remedy question is what remains open. Verdict (adjudication): confirmed, medium.

Suggested fix

Recommended A. Forward mergeInit in the point-in-time wrapper

Forward mergeInit in the wrapper in IndexWriter.preparePointInTimeMerge, as it already forwards mergeFinished: super.mergeInit(); toWrap.mergeInit(); (+6 lines). The repro and 12 related core suites (13 suites, 280 tests, assertions on and off), the whole core index test package (1396 tests) and the core lint pass.

@Override
public void mergeInit() throws IOException {
  super.mergeInit();
  toWrap.mergeInit(); // must delegate
}
All options and trade-offs4 options · 485 words

Option A was run through gradle in a scratch worktree of 645d4fe14a20. B, C and D are alternatives that were not run.

A. Forward mergeInit in the point-in-time wrapper (validated by the checks) (recommended)

Add a mergeInit override to the anonymous OneMerge in preparePointInTimeMerge that calls the wrapper's own base method and then the policy's, next to the existing // must delegate forwards.

@Override
public void mergeInit() throws IOException {
  super.mergeInit();
  toWrap.mergeInit(); // must delegate
}

Trade-offs

  • The policy's mergeInit then runs exactly where it runs for natural merges: inside _mergeInit, under IndexWriter's lock, on the merge thread. If it throws, IndexWriter handles it as for a natural merge: mergeInit(merge) runs its internal mergeFinish(merge) bookkeeping (not OneMerge#mergeFinished) and rethrows (IndexWriter.java:5261-5269); what the policy's merge sees after its own mergeInit throws was not tested.
  • With assertions on, setMergeThread's assert this.owner == null holds for the policy's merge, which has its own progress object.
  • It does not share the two progress objects, so the untested isAborted point stays.
  • Open PR #16571 rewrites the same wrapper on top of a new FilterOneMerge; the same override fits there.

What the checks validated

  • Gradle in a scratch worktree of 645d4fe14a20 (--offline, seed DEADBEEF). Stock: the repro fails with assertions on and off.
  • With the fix: the repro plus TestIndexWriterMergePolicy, TestIndexWriterReader, TestIndexWriter, TestTieredMergePolicy, TestLogMergePolicy, TestOneMergeWrappingMergePolicy, TestFilterMergePolicy, TestConcurrentMergeScheduler, TestDemoParallelLeafReader, TestTragicIndexWriterDeadlock, TestSoftDeletesRetentionMergePolicy and TestIndexWriterExceptions pass with assertions on and off (13 suites, 280 tests, 0 failures, 10 skipped).
  • The whole org.apache.lucene.index test package with the fix (215 suites, 1396 tests, 0 failures, 56 skipped), and lucene/misc TestBPReorderingMergePolicy (3 tests, 0 failures, 2 skipped).
  • Lint with the fix and the repro in place: checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass.
  • Earlier: the same forward passed the tie-break's getReader and ConcurrentMergeScheduler variants (5 of 5, -ea and -da) and, patched into 8.6.0's IndexWriter, the 8.6.0 port.

B. Document the behaviour instead (not run)

Say in OneMerge#mergeInit and findFullFlushMerges that for merges on commit and getReader IndexWriter runs its own wrapper and forwards only mergeFinished, onMergeComplete, setMergeInfo, wrapForMerge and reorder.

Trade-offs

No behaviour change and cheap, but a successful mergeFinished without mergeInit stays on one path, so every policy author has to handle it.

C. Forward mergeInit and share the progress object (not run)

Let the wrapper use the policy merge's OneMergeProgress (a new protected constructor), so abort and pause state are shared as well.

Trade-offs

Also covers the untested isAborted point, but changes the experimental wrapping constructor's semantics; a larger review.

D. Forward lifecycle hooks in #16571's FilterOneMerge (not run)

Only if that PR lands: add mergeInit (and mergeFinished) to FilterOneMerge.

Trade-offs

Changes every wrapper built on FilterOneMerge, including ones that today forward no lifecycle hook; option A on the rewritten wrapper is narrower.

Downstream note

A code search of Elasticsearch, OpenSearch and Solr found no OneMerge subclass that overrides mergeInit, so only custom merge-policy code in applications or other libraries is exposed.

How it was found

A static scan of the compiled Lucene jars listed, for every OneMerge wrapper, which overridable hooks it forwards to the merge it wraps. For IndexWriter's merge-on-commit wrapper it showed mergeFinished, onMergeComplete, wrapForMerge, reorder and setMergeInfo forwarded and mergeInit not; a JUnit test on the stock jars then showed the effect. Semi-formal.

The method in full148 words

Method: a bytecode delegation scan, then JUnit (the hunt's "semi-formal" tier).

  • A hunt unit (merge-policy hooks) extended an earlier unit's delegation-completeness scanner (MergeHookScan.java, JDK 25 java.lang.classfile) to every OneMerge and MergePolicy wrapper and every overridable hook: 6076 classes from 20 module jars, 11 wrappers, 140 matrix rows.
  • It resolves each hook on each wrapper with JVM method selection and records whether the wrapper forwards it to the wrapped object. The gap row IndexWriter$2 mergeInit()V lifecycle/result BASE (the anonymous OneMerge in preparePointInTimeMerge) said that mergeInit resolves to the base default while five sibling hooks forward.
  • Vacuity check: a planted wrapper that drops only mergeInit is flagged and a complete one is not.
  • An exploratory probe and then a public-API JUnit test on the stock jars (merge on commit, merge on getReader, a forceMerge control) confirmed it on main (assertions on and off), 10.5.2 and 9.12.3.

Prior reports, history and checks

Prior reports

No matching prior report found (searched 2026-10-26, refreshed 2026-11-01) in GitHub issues, PRs and commits, the mailing lists, the JIRA archive or downstream repositories. Closest, a partial overlap: open PR #16571 rewrites this wrapper and warns in general that "any behaviour not forwarded is lost" when a merge is wrapped, but never mentions mergeInit and still does not forward it.

Searches4 searches

No issue, PR, commit, list thread, JIRA entry or downstream report that the searches found describes this wrapper not calling the policy's mergeInit. Closest, a partial overlap: open PR #16571 (since 2026-10-29 it rewrites the wrapper on a new FilterOneMerge, and its new checkPreserved javadoc says that wrapping loses "any behaviour not forwarded"; neither forwards mergeInit, and nobody in its thread mentions it); #7380 (LUCENE-6319, 2015, a generic "Delegating OneMerge" request); #11685 (LUCENE-10649, a test policy that lacked its own findFullFlushMerges); #10448 and #10445 (other merge-on-commit lifecycle bugs: mergeFinished and closeMergeReaders called twice).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, lucene-solr, Elasticsearch, OpenSearch, Solr, Lucene.NET, all GitHub), the mailing lists and the JIRA archive2026-10-26three scripted prior-report search runs over GitHub, the lists and the JIRA archive (148 queries, 564 hits), 48 GraphQL searches (these also covered Lucene.NET and all of GitHub) and 7 web searches28 hits assessed, none the same bug
Design threads (read in full)2026-10-26LUCENE-8962 (#10005), lucene-solr #1155, #1552, #1617, #1623, #12622, LUCENE-7700, LUCENE-7688mergeInit appears only inside stack traces
GitHub, refresh, and all open PRs2026-11-012 scripted prior-report searches (99 queries), 58 GraphQL searches, 10 open-PR list terms, 7 code searches; the changed files of all 409 open PRs, the 18 touching the affected classes diffedonly #16571 changes the wrapper, without mergeInit
Mailing lists, JIRA archive, web2026-10-26, 2026-11-01dev@, java-user@, issues@, commits@ and JIRA queries in the scripted searches; 13 manual list queries and 3 JQL queries (2026-11-01); 17 manual web searches in all, plus 6 scripted web queriesno report

History

Introduced with merge on commit by #1617 (LUCENE-8962, 7f352a96659, merged 2020-06-27; first released in 8.6.0). All 50 releases from 8.6.0 to 10.6.0 and main fail; 8.5.2 has no merge on commit. Opt-in until 9.2.x, on by default since 9.3.0.

Versions tested7 rows

#1617 created the wrapper with mergeFinished and wrapForMerge forwarding and no mergeInit; mergeInit had been public since LUCENE-7700 (2017). Later changes kept the gap: #1623 (8.7.0) reused the wrapper for getReader, LUCENE-10078 (9.3.0) made the 500 ms wait the default, and #12622 (9.9.0) added reorder and setMergeInfo forwarding. Not a regression; no commit on any ref contains toWrap.mergeInit; not fixed on upstream main f815e679bf6c (2026-11-01).

Versions and branches executed, with results
Version or commitVerdictResult
8.5.0 - 8.5.2 not affectedno merge-on-commit API (the 8.x port does not compile on 8.5.2); the findMerges control passes
8.6.0 opt-inthe 8.x port (explicit 500 ms wait) fails (-ea, -da); with the forward patched into 8.6.0 it passes
8.6.1 - 9.0.0 opt-in16 releases fail (8.x port, explicit 500 ms wait)
9.1.0, 9.2.0 opt-infail with an explicit 500 ms wait; with default settings no merge on commit runs
9.3.0 - 10.6.0 affected31 releases fail, the minimal test unchanged, default settings
main 645d4fe14a20 affectedfails with -ea and -da (standalone and gradle)
fix A on 645d4fe14a20 not affectedthe minimal test and the core index test package pass

Independent checks

The finder, independent verifiers (one an OpenAI GPT-5.6 reviewer), an adjudication, an audit, a re-verification on main 645d4fe14a20 and a tie-break agree on the root cause, and every executed check reproduced it. A read-only GPT-5.6 review said "not real" at low confidence for procedural reasons: it could not run code.

Checks and verdicts278 words

Every check agreed with the root cause and the fix; the open point is only which remedy maintainers prefer.

Each independent check and its verdict
CheckVerdictWhat it found
finder (hook-forwarding scan, then JUnit) candidateIts 3-test class fails 2 of 3 (commit, getReader) on main with -ea and -da, and on 10.5.2 and 9.12.3; the forceMerge control passes; the forward passes 3 of 3.
cross-model review, refute lens (OpenAI GPT-5.6, read-only) not real, lowProcedural only: it could not run code, agreed with the root cause and found the inputs in contract; a separate run re-ran the repro (fails).
independent reproducer realOwn 4-test class fails 3 of 4 on main, 10.5.2 and 9.12.3, the direct tests also on 8.11.4 and 8.7.0; includes a stock-policy test with the default wait.
cross-model review, intended lens (OpenAI GPT-5.6) real, mediumRejected the object-identity case because mergeInit is public and the wrapper forwards mergeFinished; a separate run re-ran the repro with two seeds (fails).
intended-behaviour devil's advocate real, moderateBuilt the strongest intended case; showed reach through a plain findMerges customisation and the rollback nuance.
adjudication confirmed, mediumRe-ran the independent reproduction with seed 5EED1234 (3 of 4 fail, -ea and -da); listed the caveats.
audit, re-verification and tie-break confirmed; real; realReproduced on main 645d4fe14a20, 10.5.2 and 10.6.0; the getReader and ConcurrentMergeScheduler variants fail 10 of 10, the control passes, the forward fixes all.
history, prior art and this doc 8.6.0 first; no prior report; fix validated62 history runs (60 on releases, 2 on main); a fresh prior-art search; the gradle runs of this doc.

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

Finding record

LFV-575, snapshot of 2026-11-06

Registry title
IndexWriter's merge-on-commit / merge-on-getReader wrapper forwards mergeFinished to the merge policy's OneMerge but never calls its mergeInit, so a merge that ran and committed reaches the policy's OneMerge as mergeFinished(success=true) with no mergeInit before it (since 8.6.0)
Area
index / IndexWriter point-in-time merges (merge-on-commit, merge-on-getReader: the preparePointInTimeMerge OneMerge wrapper and the OneMerge lifecycle hooks)
Priority
Low, rank 1165: The merge-on-commit wrapper skips the OneMerge mergeInit hook, changing nothing on stock Lucene and mattering only for custom OneMerge subclasses, a lifecycle-hook gap like LFV-108.
Impact class
minor
Impact note
Hits only applications or libraries whose own merge policy hands out a OneMerge subclass that overrides the public mergeInit hook (no class in Lucene does, and none was found in Elasticsearch, OpenSearch or Solr) for a merge that runs inside commit(), prepareCommit() or getReader / DirectoryReader.open(writer): this path is on by default since 9.3.0 (maxFullFlushMergeWaitMillis 500 ms; opt-in in 8.6.0-9.2.x) and is reached even from an ordinary findMerges customisation under a stock LogDocMergePolicy, because the default findFullFlushMerges returns findMerges' OneMerge objects; such a OneMerge then gets mergeFinished(success=true) with no mergeInit before it on every such merge, with -ea and -da and no scale; on stock Lucene nothing observable changes (the skipped default only records the merge thread in a field no code reads), and merged content, deletes, search results, aborting the running merge and the commit wait bound are unaffected.
Summary
You install a merge policy whose OneMerge subclass overrides the public mergeInit hook (for example to start a timer or set up per-merge state that mergeFinished later releases) and let IndexWriter merge on commit or on getReader, which it does by default since 9.3.0. You expect that OneMerge to get mergeInit when the merge starts and mergeFinished when it ends, as the same OneMerge does when it comes from findMerges or forceMerge. Instead IndexWriter wraps the policy's merge in its own OneMerge that forwards mergeFinished and the other hooks but not mergeInit, so the policy's OneMerge sees mergeFinished(success=true) for a merge that ran and committed and never sees mergeInit (in the repro: mergeInit=0, mergeFinished=1 for the merge on commit and on getReader; 1 and 1 for forceMerge).
Introduced
7f352a96659b4d304ae3ef0c7a1b62784ce2a406 (main) and its branch_8x backport 9b1c92809e7 (shipped in 8.6.0): apache/lucene-solr#1617, LUCENE-8962 'Merge small segments on commit' (GitHub issue #10005; author s1monw, co-authors msfroh, msokolov and mikemccand; merged 2020-06-27), which created the wrapper new MergePolicy.OneMerge(toWrap.segments) with mergeFinished and wrapForMerge forwarding ('// must delegate') and no mergeInit override [read: git log -S/-G/-L and git blame in the full-history clone; exec: 8.5.2 has no findFullFlushMerges (compile error) and its pre-feature control passes, 8.6.0 fails]. The defect was there when the wrapper was created, not a later regression: OneMerge#mergeInit had been public since LUCENE-7700 (9540bc37583, 2017, first released 6.5.0 / 7.0.0). Two earlier merge-on-commit attempts were reverted before any release (043c5dff6f4 #1155, whose wrapper forwarded no hook; 972c84022ec #1552, same shape as #1617). Later changes kept the gap: 8294e1ae206 (#1623, 8.7.0) reused the wrapper for getReader as preparePointInTimeMerge and added onMergeComplete forwarding; b5795db0cf5 (LUCENE-10078, 9.3.0) made the full-flush wait 500 ms by default; f7cab164501 (#12622, 9.9.0) switched to the wrapping constructor and added reorder ('// must delegate') and setMergeInfo forwarding; no commit on main or branch_8x has ever contained toWrap.mergeInit (git log -G) (2020-06-27)
First affected version
8.6.0 [exec] (merge on commit; the getReader path from 8.7.0 [exec]); 8.5.2 and older not affected [exec 8.5.2: the full-flush API is absent (compile error) and the pre-feature control passes 2 of 2]
Affected versions
every release from 8.6.0 to 10.6.0 and main (645d4fe14a20, a9779ef3c193 and the previous main ea57360912ed). [exec, -ea] the minimal test (ported for 8.6.0-9.0.0 and 9.1-9.2) fails on all 50 releases from 8.6.0 to 10.6.0 (the history step, 2026-11-01), and on main 645d4fe14a20 with -ea and -da. [exec, -ea] the finder's JUnit or its 8.x/9.0 variant fails on 8.6.0 and 8.6.3 (commit path only; no GET_READER there), 8.7.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, main ea57360912ed and main a9779ef3c193, with the forceMerge control passing everywhere; [exec, -da] main a9779ef3c193 (finder, the independent reproduction, the intended-behaviour check and judge runs); the independent reproduction's own JUnit also fails on 10.5.2, 9.12.3, 8.11.4 and 8.7.0; the default-settings test (stock LogDocMergePolicy, only findMerges customised, default wait) passes on 9.2.0 (default wait 0, no merge on commit) and fails on 9.3.0 and main. [read] the wrapper has no mergeInit override at every release tag in the history clone. Reach: opt-in (an explicit full-flush wait > 0) in 8.6.0-9.2.x, on by default from 9.3.0. Not fixed upstream [read, 2026-10-26: upstream main a95c754ee003 still has the wrapper without a mergeInit override; no open PR changes it]. Executed on aarch64 with JDK 25 only
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal
Upstream and prior reports
none found (2026-10-26, with the finder's run and the intended-behaviour check's 29-query run): three scripted prior-report searches (wrapper/forwarding, OneMergeProgress/abort and delegating-OneMerge terms; 148 queries over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; 564 hits), 48 GitHub GraphQL searches (apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch, Solr, Lucene.NET and all of GitHub), 10 downstream code searches for mergeInit and findFullFlushMerges, 7 web searches, the changed files of all 406 open apache/lucene PRs (the diffs of the 16 that touch IndexWriter.java, MergePolicy.java or OneMergeWrappingMergePolicy.java scanned for mergeInit, toWrap, preparePointInTimeMerge, '// must delegate' and OneMergeProgress), and the full comment and review threads of LUCENE-8962 (#10005), lucene-solr #1155, #1222, #1307, #1313, #1552, #1576, #1585, #1590, #1601, #1617, #1623 and #1772, #12622, LUCENE-7700 (#8751) and LUCENE-7688 (#8739), where mergeInit appears only inside stack traces. 28 hits assessed, none the same bug. Related context, not reports: open PR #16571 (jimczi, 'Let a merge write more than one segment') adds MergePolicy.FilterOneMerge, which forwards neither mergeInit nor mergeFinished, with a javadoc line that unforwarded behaviour is lost when wrapping; since 2026-10-29 it also rewrites the preparePointInTimeMerge wrapper on FilterOneMerge, still without forwarding mergeInit; open #7380 (LUCENE-6319, 2015, 'Delegating OneMerge') is a generic request that predates the wrapper. Not fixed on upstream main a95c754ee003 (2026-10-26)
Lucene commit tested
main 645d4fe14a20 (2026-10-28; runs on 2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on and off, and standalone (-ea, -da); with the recommended fix it passes, together with 12 related core suites (13 suites with the repro, 280 tests, assertions on and off), the whole org.apache.lucene.index test package (1396 tests), TestBPReorderingMergePolicy and the core lint. The history step ran the same test (ported for 8.6.0-9.0.0 and 9.1-9.2) on all 50 releases from 8.6.0 to 10.6.0 (all fail) and on 8.5.0-8.5.2 (not affected). A tie-break run covered the getReader path and ConcurrentMergeScheduler. Upstream main f815e679bf6c (2026-11-01) has the same affected files as 645d4fe14a20 (read).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3950-4076 (at a9779ef3c193) (preparePointInTimeMerge: new MergePolicy.OneMerge(toWrap) at :3964; forwards toWrap.mergeFinished at :4027, toWrap.onMergeComplete at :4043, wrapForMerge '// must delegate' at :4061, reorder '// must delegate' at :4067, toWrap.setMergeInfo at :4073; initMergeReaders only once at :4047-4057; no mergeInit override), :5272-5293 (_mergeInit: merge.mergeInit() on the executing object at :5289), :3493 (addIndexes reader merge calls mergeInit first), :2821-2834, :5133-5136, :5394-5401 (rollback/abort: never-started merges get mergeFinished(false,false) without mergeInit)
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:260-266 (at a9779ef3c193) (wrapping constructor allocates a new OneMergeProgress), :268-274 (mergeInit javadoc 'Called by IndexWriter after the merge started and from the thread that will be executing the merge'; body setMergeThread), :276-282 (mergeFinished javadoc 'Called by IndexWriter after the merge is done and all readers have been closed'), :664-712 (findFullFlushMerges javadoc and default: natural merges from findMerges below maxFullFlushMergeSize), :104-111 and :178-181 (OneMergeProgress owner, written by setMergeThread, sanity checks only)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:108 (at a9779ef3c193) (DEFAULT_MAX_FULL_FLUSH_MERGE_WAIT_MILLIS = 500)
  • lucene/core/src/java/org/apache/lucene/index/OneMergeWrappingMergePolicy.java:69-72 (at a9779ef3c193) (findFullFlushMerges wraps the inner policy's spec)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:634-638, 2821-2834, 3858-3864, 3950-4110, 5075, 5133-5136, 5256-5293, 5394-5403
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:178-181, 184-191, 259-299, 664-714; IndexWriterConfig.java:108; OneMergeWrappingMergePolicy.java:68-73
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:106-111, 178-181; IndexWriterConfig.java:108
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:268-282; IndexWriter.java:4027-4073; IndexWriterConfig.java:482-487
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:8405-8409; lucene/core/src/test/org/apache/lucene/index/TestIndexWriterMergePolicy.java:877-936
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5256-5270; MergePolicy.java:178-181, 259-266

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.

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.