- A merge policy describes each merge as a
MergePolicy.OneMerge. A subclass can override public hooks that IndexWriter calls, among themmergeInitwhen the merge starts andmergeFinishedwhen it is done. - Since 8.6.0,
commit()(and since 8.7.0getReader) can merge small segments before it returns, usingMergePolicy#findFullFlushMerges; by default that method returns the policy's ordinaryfindMergesmerges below a size limit. Since 9.3.0 this is on by default: wait up to 500 ms. - For these merges IndexWriter does not run the policy's
OneMergeitself: it wraps it in its ownOneMerge, which swaps the merged segment into the commit or reader being opened.
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.- Write a merge policy whose
OneMergesubclass overrides the public hooksmergeInit("the merge started") andmergeFinished("the merge is done"), for example to time each merge. Keep the defaultIndexWriterConfig: since 9.3.0commit()waits up to 500 ms for small merges the policy returns. - Add one document and commit, then add another and commit. The second commit merges the 2 one-document segments into 1 before it returns.
- You expect the policy's
OneMergeto getmergeInitonce andmergeFinishedonce, as the same class does when it comes fromfindMergesorforceMerge. - Instead it gets
mergeFinished(success=true)once andmergeInit0 times. For this merge IndexWriter runs its own wrapperOneMerge, which passesmergeFinishedand four other hooks on to the policy's merge, but notmergeInit. - 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 overridesmergeInit.
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.
OneMergehooks:MergePolicy.OneMergeis 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) andIndexWriterConfig#setMaxFullFlushMergeWaitMillis, defaultDEFAULT_MAX_FULL_FLUSH_MERGE_WAIT_MILLIS = 500(IndexWriterConfig.java:108).
commit()callspreparePointInTimeMerge(..., MergeTrigger.COMMIT, ...)when the wait is above 0 (IndexWriter.java:3858-3864);getReadercalls it withGET_READER(:634-638).preparePointInTimeMerge(:3950-4110) runs the configured policy through aOneMergeWrappingMergePolicywhose function replaces every merge withnew MergePolicy.OneMerge(toWrap) { ... }(:3959-3964). IndexWriter registers and runs only that wrapper.- The wrapper forwards
toWrap.mergeFinished(:4027),toWrap.onMergeComplete(:4043),toWrap.wrapForMergeandtoWrap.reorder(both commented// must delegate,:4061,:4067) andtoWrap.setMergeInfo(:4073). It has nomergeInitoverride. 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).- When the merge closes,
OneMerge.closecalls the wrapper'smergeFinished(MergePolicy.java:285-293), which passes its arguments on astoWrap.mergeFinished(committed, segmentDropped)(:4027); for the repro's merge that is(true, false).
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.
- A merge on
DirectoryReader.open(writer)(GET_READER), and a merge on commit with the defaultConcurrentMergeScheduler: events[finished:true:false]instead of[init, finished:true:false], 10 of 10 iterations each. - A policy that overrides only
findMergeson a stockLogDocMergePolicy, default settings: the defaultfindFullFlushMergesreturns the same objects, which getinit=0 finished=1. (The default keeps merges whose segments are belowmaxFullFlushMergeSize():minMergeSizeinLogMergePolicy,floorSegmentBytesinTieredMergePolicy, 0 in the baseMergePolicy,MergePolicy.java:730-736.)
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
OneMergesubclass that overridesmergeInit, for merges that run insidecommit(),prepareCommit()orgetReader/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.
mergeFinishedwith nomergeInitbefore it, on every such merge; for a merge that succeeds and keeps its segment, as in the repro, that ismergeFinished(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 overridesmergeInit(at645d4fe14a20). 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
mergeInitoverride in Elasticsearch, OpenSearch or Solr (not exhaustive), and nobody has reported it since 8.6.0.
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.
/*
* 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]);
}
}
}
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
OneMergegotmergeInitonce andmergeFinishedonce. - Observed
- 1 segment and
mergeFinishedonce, butmergeInit0 times:mergeInit calls before mergeFinished expected:<1> but was:<0>(assertions on and off).
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).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails with assertions on and off; with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# 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)mergeInitis public, documented as "Called by IndexWriter after the merge started", with no exception for merges on commit.- The wrapper forwards the paired
mergeFinishedand four other hooks but notmergeInit; no other IndexWriter path reports a successful finish without it. - Upstream added the missing
setMergeInfoforward to this same wrapper in #12622.
The full argument201 words
- Contract.
mergeInitis 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,wrapForMergeandreorder(IndexWriter.java:4027-4073).IndexWriterConfig.java:482-487says 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
reorderandsetMergeInfoforwards. 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.testSetDiagnosticswraps bothfindMergesandfindFullFlushMergesmerges. - No documented rationale found. The wrapper never had
mergeInit(first version7f352a96659, #1617); no commit in the history containstoWrap.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.
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.
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
mergeInitthen 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 internalmergeFinish(merge)bookkeeping (notOneMerge#mergeFinished) and rethrows (IndexWriter.java:5261-5269); what the policy's merge sees after its ownmergeInitthrows was not tested. - With assertions on,
setMergeThread'sassert this.owner == nullholds for the policy's merge, which has its own progress object. - It does not share the two progress objects, so the untested
isAbortedpoint 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,TestSoftDeletesRetentionMergePolicyandTestIndexWriterExceptionspass with assertions on and off (13 suites, 280 tests, 0 failures, 10 skipped). - The whole
org.apache.lucene.indextest package with the fix (215 suites, 1396 tests, 0 failures, 56 skipped), andlucene/miscTestBPReorderingMergePolicy(3 tests, 0 failures, 2 skipped). - Lint with the fix and the repro in place:
checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestpass. - Earlier: the same forward passed the tie-break's getReader and
ConcurrentMergeSchedulervariants (5 of 5,-eaand-da) and, patched into 8.6.0'sIndexWriter, the 8.6.0 port.
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.
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.
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.
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.
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 25java.lang.classfile) to everyOneMergeandMergePolicywrapper 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 anonymousOneMergeinpreparePointInTimeMerge) said thatmergeInitresolves to the base default while five sibling hooks forward. - Vacuity check: a planted wrapper that drops only
mergeInitis 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
forceMergecontrol) confirmed it on main (assertions on and off), 10.5.2 and 9.12.3.
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).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, lucene-solr, Elasticsearch, OpenSearch, Solr, Lucene.NET, all GitHub), the mailing lists and the JIRA archive | 2026-10-26 | three 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 searches | 28 hits assessed, none the same bug |
| Design threads (read in full) | 2026-10-26 | LUCENE-8962 (#10005), lucene-solr #1155, #1552, #1617, #1623, #12622, LUCENE-7700, LUCENE-7688 | mergeInit appears only inside stack traces |
| GitHub, refresh, and all open PRs | 2026-11-01 | 2 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 diffed | only #16571 changes the wrapper, without mergeInit |
| Mailing lists, JIRA archive, web | 2026-10-26, 2026-11-01 | dev@, 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 queries | no report |
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).
| Version or commit | Verdict | Result |
|---|---|---|
| 8.5.0 - 8.5.2 | not affected | no merge-on-commit API (the 8.x port does not compile on 8.5.2); the findMerges control passes |
| 8.6.0 | opt-in | the 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-in | 16 releases fail (8.x port, explicit 500 ms wait) |
| 9.1.0, 9.2.0 | opt-in | fail with an explicit 500 ms wait; with default settings no merge on commit runs |
| 9.3.0 - 10.6.0 | affected | 31 releases fail, the minimal test unchanged, default settings |
main 645d4fe14a20 | affected | fails with -ea and -da (standalone and gradle) |
fix A on 645d4fe14a20 | not affected | the minimal test and the core index test package pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (hook-forwarding scan, then JUnit) | candidate | Its 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, low | Procedural 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 | real | Own 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, medium | Rejected 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, moderate | Built the strongest intended case; showed reach through a plain findMerges customisation and the rollback nuance. |
| adjudication | confirmed, medium | Re-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; real | Reproduced 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 validated | 62 history runs (60 on releases, 2 on main); a fresh prior-art search; the gradle runs of this doc. |
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 430 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,473 words
- 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 wholeorg.apache.lucene.indextest package (1396 tests),TestBPReorderingMergePolicyand 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 andConcurrentMergeScheduler. Upstream mainf815e679bf6c(2026-11-01) has the same affected files as645d4fe14a20(read).
- 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-5403a 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-487a 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
- TestMergeOnCommitCallsMergeInit.java:
repro/LFV-575/src/org/apache/lucene/index/TestMergeOnCommitCallsMergeInit.java(70 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.
assets/LFV-575/bug.png(430 KB)
