split() closes the writer it opened only when addIndexes succeeds; there is no try/finally around it.
MultiPassIndexSplitter(modulelucene-misc) splits one index into N parts. For each part it opens anIndexWriteron that part's output directory and copies the part's documents in withIndexWriter.addIndexes.- An open
IndexWriterholds its directory's write lock (write.lock). A secondIndexWriteron the same directory getsLockObtainFailedExceptionuntil the first one is closed or rolled back. - With the default
FSDirectorylock factory, the JVM also records every held lock path in one JVM-wide set; only closing that lock removes the path again. addIndexescan throw an ordinary exception (a full disk, a corrupted input, an input created by another major version) and leaves the writer open when it does, so whoever opened the writer must close or roll it back.
split() opens a writer on out1 (taking write.lock), addIndexes throws, close() is skipped, and the JVM-wide lock record makes every later IndexWriter on out1 fail. Select the still to open it at full size. Generated with AI, checked against the code.- You split a 2-document index into
out1andout2withnew MultiPassIndexSplitter().split(reader, new Directory[] {out1, out2}, false). The input was created by the previous major version (10; main is 11). split()opens anIndexWriteronout1and callsaddIndexes, which rejects the input:IllegalArgumentException: Cannot merge a segment that has been created with major version 10 into this index which has been created by major version 11.- You expect the failed split to close the writer it opened, so a new
IndexWriteronout1(or a retried split with a different input) opens. - Instead
split()skipped itsw.close(): the writer is still open and nobody can reach it, sonew IndexWriter(out1, ...)throwsLockObtainFailedException(onFSDirectory: "Lock held by this virtual machine"). OnFSDirectory, closing theDirectory,System.gc()and reopening do not help; only a JVM restart does. - An I/O error while a part is written (for example a full disk; simulated in the tests), or a corrupted source file (
CorruptIndexException), locks the output the same way. Nothing is corrupted: the failed output gets no commit and the source index is unchanged.
Applications that call the misc tool's split(...) from Java in a long-running JVM and then reuse an output path after a failed split: a retry once space is freed or with a converted input, or cleanup that opens a writer there. The command-line tool is unaffected. One path stays locked per failed split; nothing is corrupted.
Mechanism, terms and numbers683 words
MultiPassIndexSplitter.split(IndexReader, Directory[], boolean), the misc module's index splitter, opens an IndexWriter on each output directory, copies that part's documents in with addIndexes and calls close() only when addIndexes returns normally. If addIndexes throws, for example because the source index was created by the previous major version, a source file is corrupted or the disk fills up, the writer stays open. No caller can reach it, so the output's write lock is never released: every later IndexWriter on that directory, including a retried split, fails with LockObtainFailedException, and with the default FSDirectory lock factory this lasts until the JVM exits, even after the Directory is closed and garbage is collected. The code dates from the class's first commit (LUCENE-1959, 2009), so every release from 3.0.0 to 10.6.0 that was run or read, and main, have it; the sibling PKIndexSplitter has closed its writer on failure since 2011. The command-line main() is unaffected because its JVM exits on the exception, and nothing is corrupted. A few lines fix it: roll the writer back when addIndexes throws, or wrap it in try-with-resources.
All lines are at 645d4fe14a20; MultiPassIndexSplitter.java unless named.
- For each part,
split()opensnew IndexWriter(outputs[i], new IndexWriterConfig(null).setOpenMode(OpenMode.CREATE))(:108-109), printsWriting part i ...(:110), callsw.addIndexes(...)(:113) and thenw.close()(:114). Notry/finally, norollback(), no try-with-resources;wnever leaves the method. - Opening the writer takes the lock: "Opening an
IndexWritercreates a lock file for the directory in use. Trying to open anotherIndexWriteron the same directory will lead to aLockObtainFailedException" (IndexWriter.java:137-139);close()"Closes all open resources and releases the write lock" (:1342). addIndexes(CodecReader...)catches onlyErrorand makes it a tragic event (IndexWriter.java:3390-3391); anIOException,CorruptIndexExceptionorIllegalArgumentExceptionpropagates with the writer still open. Its javadoc listsCorruptIndexExceptionandIOException(:3270-3271) and points toaddIndexes(Directory...), which names "disk full" as an expected exception (:3084-3086).- The deterministic trigger:
split()always builds its outputs withnew IndexWriterConfig(null)(:109), the current major version, andvalidateMergeReader(IndexWriter.java:3228-3236, called first at:3290) rejects a source leaf with another created-version major. No merge thread starts. - With
FSDirectory,NativeFSLockFactorykeeps held lock paths inprivate static final Set<String> LOCK_HELD(NativeFSLockFactory.java:68) and throws "Lock held by this virtual machine" for a path already in it (:119); onlyNativeFSLock.close()removes the path (:145,:212). The leaked writer's lock is never closed, so the path stays refused until the JVM exits; garbage collection can drop the file handle but not the record. With the handle gone, another JVM could then lock the path (code reading, not run); the refusal is inside the JVM that ran the split. main()callssplit()(:182) and declaresthrows Exception, so for the command-line tool the exception ends the JVM and the OS releases the lock. A program that callsmain()and survives its exception is hit like any other caller (code reading).
- Corrupted source: one flipped byte in the source's
.fdtfile makessplit()throwCorruptIndexException: checksum failedfrom the merge; the output, which never had an I/O problem, then refuses a new writer with "Lock held by this virtual machine". - Full disk (simulated): an output whose
createOutputthrowsIOExceptionleaves it locked the same way, and a retried split into the same outputs fails with the same message after the fault is removed. - With both, the merge runs on a
ConcurrentMergeSchedulerthread, which also prints an uncaughtMergeExceptionto stderr. That is normal behaviour and not part of this bug;split()itself throws the original exception.
Previous-major note: that setIndexCreatedVersionMajor(LATEST.major - 1) behaves like an index really written by the previous release is code reading (both carry created-version major N-1, the only value this check reads; IndexWriterConfig.java:173-180 documents the setter for exactly this addIndexes restriction). That split() cannot split a previous-major index at all is a separate limitation and not part of this bug.
Exposure and likelihood182 words
- Who is hit. Java callers of
MultiPassIndexSplitter.split(...)in a JVM that keeps running after a failed split: a service, a maintenance tool, a test suite. Not the command-linemain(). - Default vs opt-in. The class is in the optional
lucene-miscmodule; inside it nothing else is needed. The defaultFSDirectoryandNativeFSLockFactorygive the JVM-lifetime lock; assertions make no difference. - Effect. One leaked open
IndexWriterper failed split; that output path refuses new writers until the JVM restarts, other paths keep working (a split into fresh directories succeeds in the same JVM). - No damage. No hang (the slowest failed split threw in 168 ms), no wrong results, no corruption: the failed output gets no commit, an index already there keeps its previous commit, the source index is unchanged. This was run for the previous-major trigger; for the merge-time triggers it follows from the code: nobody can commit or close the leaked writer.
- Scale. None needed: 2 documents reproduce it. In the hunt's fault-injection sweep, a one-shot
IOExceptionat a single file-system event left the output locked in 18,654 of 18,866 split runs.
One JUnit test, no fault injection, ByteBuffersDirectory only: it builds a 2-document index created by the previous major version, expects split() to throw IllegalArgumentException, then opens a new IndexWriter on out1. Deterministic; the same failure with assertions on and off.
/*
* 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.misc.index;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.LockObtainFailedException;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.Version;
public class TestMultiPassIndexSplitterFailedSplit extends LuceneTestCase {
public void testFailedSplitReleasesOutputLock() throws Exception {
try (Directory input = new ByteBuffersDirectory();
Directory out1 = new ByteBuffersDirectory();
Directory out2 = new ByteBuffersDirectory()) {
// an index created by the previous major version, which addIndexes into split()'s outputs rejects
IndexWriterConfig iwc =
new IndexWriterConfig(null).setIndexCreatedVersionMajor(Version.LATEST.major - 1);
try (IndexWriter w = new IndexWriter(input, iwc)) {
w.addDocument(new Document());
w.addDocument(new Document());
}
try (DirectoryReader reader = DirectoryReader.open(input)) {
expectThrows(
IllegalArgumentException.class,
() -> new MultiPassIndexSplitter().split(reader, new Directory[] {out1, out2}, false));
}
try {
new IndexWriter(out1, new IndexWriterConfig(null)).close();
} catch (LockObtainFailedException e) {
fail("expected the failed split to release out1's write lock, but: " + e.getMessage());
}
}
}
}
cp $REPRO/TestMultiPassIndexSplitterFailedSplit.java $LUCENE/lucene/misc/src/test/org/apache/lucene/misc/index/
cd $LUCENE && ./gradlew :lucene:misc:test --tests org.apache.lucene.misc.index.TestMultiPassIndexSplitterFailedSplit -Ptests.seed=DEADBEEF
# same failure with -Ptests.asserts=false
- Expected
split()throwsIllegalArgumentException, and a newIndexWriteronout1then opens.- Observed
new IndexWriter(out1, ...)throwsLockObtainFailedException: lock instance already obtained ... lockName=write.lock(onFSDirectory: "Lock held by this virtual machine").
More files, environment, full output274 words
Two documents, because split() refuses fewer than 2. The previous-major input is the one trigger that fails before any merge thread starts; the full-disk and corrupted-input triggers fail inside the hardcoded ConcurrentMergeScheduler, whose merge thread reports an uncaught MergeException that a test would have to handle. With FSDirectory instead of ByteBuffersDirectory the message is "Lock held by this virtual machine: .../write.lock", but the test framework then adds a second, suite-level failure for the leaked write.lock file handle (the same leak), so the minimal test keeps ByteBuffersDirectory.
As the regression test: the same test, for example as a new method in TestMultiPassIndexSplitter; it fails on stock and passes with either fix.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT) and 10.6.0: fail with assertions on and off; main with fix A or B: passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on (trimmed; the same with -Ptests.asserts=false)
> Task :lucene:misc:test FAILED
TestMultiPassIndexSplitterFailedSplit > testFailedSplitReleasesOutputLock FAILED
java.lang.AssertionError: expected the failed split to release out1's write lock, but: lock instance already obtained: (dir=ByteBuffersDirectory@678bbc81 lockFactory=org.apache.lucene.store.SingleInstanceLockFactory@29488613, lockName=write.lock)
at __randomizedtesting.SeedInfo.seed([DEADBEEF:C546DB47F853A75B]:0)
at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
at org.apache.lucene.misc.index.TestMultiPassIndexSplitterFailedSplit.testFailedSplitReleasesOutputLock(TestMultiPassIndexSplitterFailedSplit.java:50)
2> Writing part 1 ...
1 test completed, 1 failed
# the exception split() threw (fix-comparison probe, same input, main 645d4fe14a20)
IllegalArgumentException: Cannot merge a segment that has been created with major version 10 into this index which has been created by major version 11
# the same test with FSDirectory instead of ByteBuffersDirectory (default NativeFSLockFactory)
java.lang.AssertionError: expected the failed split to release out1's write lock, but: Lock held by this virtual machine: $DIR/write.lock
java.lang.RuntimeException: file handle leaks: [FileChannel($DIR/write.lock)] (the test framework's suite-level report of the same leak)split()creates the writer as a local variable and never hands it out, so onlysplit()can close it;addIndexesleaves the writer open after an ordinary exception.- The sibling
PKIndexSplitterhas closed its writer on failure since 2011 (LUCENE-2919). - No comment, test or discussion chose this; the upstream test calls
split()directly as a library API.
The full argument188 words
- Only
split()can release the writer. It is a local variable (MultiPassIndexSplitter.java:108-109); theIndexWriterjavadoc ties the lock to an open writer (IndexWriter.java:137-139), andaddIndexeskeeps the writer open after a non-Errorexception (:3390-3391), so closing it is the opener's job. - A public library API that expects failures.
split()documents@throws IOException If there is a low-level I/O error(:68); the upstreamTestMultiPassIndexSplittercallssplit()directly (testSplitRR,testSplitSeq), notmain(). - The sibling does it on purpose.
PKIndexSplitterwas added in LUCENE-2919 (bba6b01826d, 2011-06-17) and got failure-path closing the next day (40f5948b3df, Uwe Schindler,IOUtils.closeSafely(!success, w)); todaytry (IndexWriter w = new IndexWriter(target, config))wraps the sameaddIndexescall (PKIndexSplitter.java:117-130, since #14913).IndexRearrangerin the same package also uses try-with-resources. - Nobody chose it. The LUCENE-1959 thread (#3034, 30 comments) never discusses exceptions, cleanup or the lock; none of the 16 later commits that touched the block added or rejected cleanup; a different leak in this class (the
MultiReader) was fixed as a bug in 2012 (1f192290c5a).
The devil's-advocate check195 words
The strongest case for "works as designed" (the devil's-advocate verifier and a reviewer from another model family, OpenAI GPT-5.6): the class is a command-line tool (@SuppressForbidden(reason = "System.out required: command line tool"), and main() never closes its readers either), so it relies on JVM exit for cleanup; split() promises only IOException and nothing about the outputs after a failure; a half-written output must be thrown away anyway; and the first trigger was synthetic fault injection.
Why it fails: split() is a separate public method with full javadoc that the upstream test calls directly; the caller cannot release the writer at all; the lock blocks the path for the JVM's whole life, even for an output that never had an I/O problem (the corrupted-input case); the previous-major trigger needs no fault injection at all, and the corrupted-source one no faulty directory (the test flips one byte of a source file); and the sibling class has handled the same step exception-safely since 2011. Every verifier rated this argument weak; the cross-model fact check of this doc rated it moderate and still called the behaviour a minor bug. It bounds the impact (minor), not the validity.
Recommended B. Roll back on failure, close on success
Roll the writer back when addIndexes throws and close it only on success (catch (Throwable t) { IOUtils.closeWhileSuppressingExceptions(t, w::rollback); throw t; }): the lock is released and a failed output keeps its earlier contents. The repro and the misc module's 317 tests pass. Try-with-resources (as in PKIndexSplitter) also works but commits an empty index on failure.
All options and trade-offs2 options ยท 738 words
Both options were run through gradle in a scratch worktree of 645d4fe14a20, and both fix the repro and pass the closest suite. They differ in what a failed part leaves in its output directory: a comparison probe split a previous-major input into an output that already held a 3-document index. Stock and B leave the 3 documents; A replaces them with an empty index. B is recommended because it keeps that outcome; A is the smaller change and matches the sibling class.
In split() (MultiPassIndexSplitter.java:108-114): wrap the addIndexes step in try/catch (Throwable t), roll the writer back there and rethrow; keep w.close() after it for the success path. +11/-4, including one import (org.apache.lucene.util.IOUtils).
IndexWriter w =
new IndexWriter(outputs[i], new IndexWriterConfig(null).setOpenMode(OpenMode.CREATE));
- System.err.println("Writing part " + (i + 1) + " ...");
- ...
- w.addIndexes(sr.toArray(CodecReader[]::new)); // TODO: maybe take List<IR> here?
+ try {
+ System.err.println("Writing part " + (i + 1) + " ...");
+ ...
+ w.addIndexes(sr.toArray(CodecReader[]::new)); // TODO: maybe take List<IR> here?
+ } catch (Throwable t) {
+ // release the output's write lock without committing anything into it
+ IOUtils.closeWhileSuppressingExceptions(t, w::rollback);
+ throw t;
+ }
w.close();Trade-offs
- Keeps today's outcome for the output of a failed part: nothing is committed (
rollback()returns the directory to its last commit,IndexWriter.java:2566-2571), so an index already there keeps its documents (probe: 3 before, 3 after), and an empty output stays without an index (probe: no files), which marks the part as failed. - An exception from
rollback()is added to the original one as suppressed (anErrorfromrollback()is thrown instead, with the original suppressed into it,IOUtils.java:114-136); after anErrorthe tragic-event handling has already rolled the writer back (IndexWriter.java:6090-6106), and the secondrollback()does nothing (:2576-2584). - The same helper as
NativeFSLockFactory.tryLock(NativeFSLockFactory.java:139-141); slightly longer than A.
What the checks validated
- Stock
645d4fe14a20: the repro fails with assertions on and off. - With B: the repro passes with assertions on and off;
TestMultiPassIndexSplitterpasses together with it (2 classes, 3 tests, 0 failures); the comparison probe keeps the 3 documents; the whole:lucene:misc:testwith the repro in place passes (24 classes, 317 tests, 0 failures, 9 skipped, all in other classes). - The independent reproducer's public-API program on
645d4fe14a20(plainFSDirectory; a simulated full disk, then a corrupted.fdt), with B: after each failed split a new writer opens, and after the simulated full disk the retried split succeeds, assertions on and off; stock fails. - The module's
checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestpass with B and the repro in place once the repro's long comment is wrapped onto two lines, which the formatter asks for (comment only; the fixed class was not flagged).
Wrap the per-part writer in try (IndexWriter w = new IndexWriter(...)) { ... } and drop the explicit w.close() (MultiPassIndexSplitter.java:108-114), +7/-7. This is the hunt's fix.
- IndexWriter w =
- new IndexWriter(outputs[i], new IndexWriterConfig(null).setOpenMode(OpenMode.CREATE));
- ...
- w.addIndexes(sr.toArray(CodecReader[]::new)); // TODO: maybe take List<IR> here?
- w.close();
+ try (IndexWriter w =
+ new IndexWriter(outputs[i], new IndexWriterConfig(null).setOpenMode(OpenMode.CREATE))) {
+ ...
+ w.addIndexes(sr.toArray(CodecReader[]::new)); // TODO: maybe take List<IR> here?
+ }Trade-offs
- The smallest change and the sibling's idiom (
PKIndexSplitter.java:117). - On failure
close()commits:commitOnClosedefaults to true (IndexWriterConfig.java:102),close()commits when it is set (IndexWriter.java:1363-1368), and a CREATE-mode writer records a pending change when it opens (IndexWriter.java:1017-1019). So a failed part leaves a valid, empty index in its output (probe:segments_1) and replaces any index that was there (probe: 3 documents before, 0 after). - If that commit fails too (for example the disk is still full),
close()still closes the writer and releases the lock (IndexWriter.java:1351-1352); its exception is suppressed into the original one.
What the checks validated
- With A: the repro passes with assertions on and off;
TestMultiPassIndexSplitterpasses together with it (2 classes, 3 tests, 0 failures); the comparison probe shows the 3 documents replaced by an empty index. - Earlier checks of the same edit on main
ea57360912ed: the finder's, the reproducer's, the cross-model adversary's and the devil's-advocate verifier's tests all pass with it, and upstreamTestMultiPassIndexSplitterpasses 2 of 2.
A read-only code search found no call to MultiPassIndexSplitter in Elasticsearch, OpenSearch or Solr, so the exposure is applications that use the misc tool's split() directly.
Semi-formal: a custom bytecode abstract interpreter with explicit exception edges, run over the stock jars, flagged the writer as still open on the exception edge of addIndexes. A fault-injection sweep then found the output lock leaked in 18,654 of 18,866 split runs (0 of 19,020 for the sibling PKIndexSplitter), and a JUnit confirmed it.
The method in full164 words
Method: exception-edge resource scan (the study's semi-formal tier). A hunting AI tool wrote ResourceScan, a bytecode abstract interpreter that tracks acquired resources (directory locks, writers, readers, inputs) along every exception edge, with interprocedural summaries, and ran it over the stock main jars (core, codecs, facet, suggest, misc, replicator: 3,388 classes, 24,094 methods). Vacuity check: all 7 planted leaks were flagged and none of 8 clean idioms.
- It reported the
IndexWriteracquired atMultiPassIndexSplitter.java:109as live on the exception edge ofIndexWriter.addIndexes(:113); the AI tool triaged the group by reading the source. - A dynamic single-fault sweep then re-ran a split once per file-system event with a one-shot
IOExceptionat that event (seed 1531531531): the output lock leaked in 18,654 of 18,866MultiPassIndexSplitter.splitruns and in 0 of 19,020PKIndexSplitter.splitruns. - A JUnit with an injected full disk confirmed it on the stock jars; an independent verifier added the corrupted-input trigger and the devil's-advocate verifier the previous-major trigger, which became the minimal repro.
No prior report found: 160 queries over GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream repositories and the web (2026-10-26; re-checked 2026-11-01). Closest: #7044 (the splitters should lock the source index, a different defect) and LUCENE-2919 (the sibling's exception-safe close).
Searches4 searches
Verdict new. All 15 hits are other defects or context. The bug is not fixed upstream, and none of the three open PRs that touch the file (#13951, #14996, #16847) changes the writer's lifecycle.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, lucene-solr, solr, lucenenet, Elasticsearch, OpenSearch, GitHub-wide) | 2026-10-26; re-checked 2026-11-01 | two scripted prior-report searches (103 queries in all: the class, split/addIndexes, the lock and leak symptoms, "Lock held by this virtual machine", the sibling splitters, try-with-resources) and 31 GraphQL searches; re-check: MultiPassIndexSplitter issues and PRs, "splitter write.lock", "split addIndexes LockObtainFailedException", the file's newest commits | no report; the same 14 issues name the class (none about a failed split or a held lock); no PR |
| Open PRs by touched file | 2026-10-26 | the changed-file lists of all 407 open apache/lucene PRs | #13951, #14996 and #16847 touch the file; none changes the writer's lifecycle |
| Related items (read) | 2026-10-26 | issue threads and commits | #7044 / LUCENE-5982 (open: splitters should take the write lock on the source; a different defect); #3993 / LUCENE-2919 (the sibling's exception-safe close; a precedent); #3034 / LUCENE-1959 (added the class; no cleanup discussion); 1f192290c5a (2012, a different leak in this class) |
| JIRA archive, mailing lists, web | 2026-10-26 | JQL over LUCENE and SOLR (17 issues naming the class read in full), lists.apache.org (java-user, dev, general, solr-user, issues), 12 web searches | no report; the Lucene.NET port disposes the writer with a using statement (a port idiom, not a report) |
Present since the class was added (LUCENE-1959, 7d0d4ecc44c, 2009-10-13), first released in 3.0.0; none of the 16 later commits that touched the block added cleanup. It fails on every release run, from 3.0.0 to 10.6.0, and on main.
Versions tested7 rows
Introduced by 7d0d4ecc44c "LUCENE-1959 Add MultiPassIndexSplitter." (Andrzej Bialecki, 2009-10-13; svn r824798; JIRA LUCENE-1959, now #3034; no PR). The original split() already opened the writer, called addIndexes and closed it only on success. git log -L follows the block through the renames; the 16 later commits that touched it are API migrations and cosmetic changes (among them LUCENE-4246 and LUCENE-5871, which renamed close() to shutdown() and back), and none added try/finally, rollback() or try-with-resources. Up to 5.0.0 the second writer reports "Lock obtain timed out" (the 1 s lock wait of that era); from 5.5.5 on it fails at once with "Lock held by this virtual machine". 2.9.x does not ship the class. Releases not run (3.0.1, most minors) have the same block by reading; 3.0.2, 3.0.3 and 8.8.2-8.11.3 were neither run nor in the history clone. Every run used JDK 25; Windows and x86 were not run.
| Version or commit | Verdict | Result |
|---|---|---|
| 2.9.0, 2.9.4 | no class | the class does not exist (the port fails to compile only on its import and use) |
| 3.0.0, 3.6.2, 4.10.4, 5.0.0 | affected | public-API port fails: the new writer gets "Lock obtain timed out"; the control passes |
| 5.5.5, 6.6.6, 7.7.3, 8.11.4 | affected | port fails: "Lock held by this virtual machine"; the control passes |
| 9.0.0, 9.12.3, 10.0.0, 10.5.2 | affected | the hunt's and verifiers' tests and ports fail (assertions on and off where run) |
| 10.6.0 | affected | the minimal test fails with assertions on and off |
main 645d4fe14a20 | affected | the minimal test fails through gradle with assertions on and off; passes with fix A or B |
upstream main 2f424fd428da | by reading | the file's newest commit is 54283150a816 (2026-06-14, cosmetic); the block is unchanged |
Confirmed with high confidence: an independent reproducer (two more triggers, five releases), a reviewer from another model family, a devil's-advocate verifier (a trigger without fault injection) and an adjudication all reproduced it or agreed, and each rated the works-as-designed argument weak.
Checks and verdicts346 words
Everyone agrees on the root cause and on the minor severity; the caveats are about the wording (that one trigger needs no fault injection, that the command-line tool is unaffected) and the fix design.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Exception-edge scan plus single-fault sweep; JUnit with an injected full disk fails on main (assertions on and off), 10.5.2 and 9.12.3; passes with try-with-resources; upstream TestMultiPassIndexSplitter 2 of 2 with it. |
| cross-model review, refute lens (OpenAI GPT-5.6, read-only) | real, high | Called the fault-injection trigger synthetic and the full-disk wording too strong; the wording was fixed. |
| independent reproducer | real | Own public-API program on plain FSDirectory: a simulated full disk and a corrupted .fdt both leave the output locked, System.gc() does not help; fails on main, 10.5.2, 9.12.3 and 9.0.0; its fix passes. |
| cross-model adversary (OpenAI GPT-5.6, read-only) | real, medium | Works-as-designed lens: argument weak, root cause agreed. The refute-lens run gave no verdict. The review's own disk-full variant fails on main and 10.5.2 and passes with the fix. |
| devil's-advocate verifier | real, high | Added the previous-major trigger (no fault injection) and showed the caller cannot release the lock (close, GC, reopen); fails on main, 10.5.2 and 9.12.3; passes with the fix. |
| adjudication | confirmed, high | Re-ran the reproducer (fails), the previous-major test with assertions off (fails) and the fix (passes); class minor. |
| re-runs, audit, minimisation, this doc | confirmed | Re-runs with new seeds at registration fail; the audit confirms it; the minimal test fails on 645d4fe14a20 and 10.6.0 with assertions on and off; the gradle runs of this doc validate both fixes. |
| fact checks of this doc | real, minor | An independent fact check found no factual errors and re-ran the repro and both fixes. A reviewer from another model family (OpenAI GPT-5.6, read-only) agreed the bug is real and minor; its wording points were fixed (the full disk is simulated, which trigger needs no fault injection, an Error from rollback()), and fix B was then also run against the full-disk and corrupted-source program (passes). |
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 485 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,416 words
- Registry title
- MultiPassIndexSplitter.split never closes the IndexWriter it opened on an output when addIndexes throws, so that output directory stays write-locked and every later IndexWriter or split into it in the same JVM fails with LockObtainFailedException until the JVM restarts
- Area
- misc / index tools (MultiPassIndexSplitter.split, failure cleanup)
- Priority
- Low, rank 1085: MultiPassIndexSplitter leaves its output write-locked after a failed addIndexes, only in the rarely used misc tool on same-JVM retry.
- Impact class
- minor
- Impact note
- Only programmatic callers of the lucene/misc MultiPassIndexSplitter (split(IndexReader, Directory[], boolean); the command-line main() is unaffected because its JVM exits on the exception) are hit, when a split throws inside addIndexes and the same JVM then reuses that output path. Three executed triggers: an I/O error while an output is written (a full disk), a corrupted source index (CorruptIndexException from the merge's checksum check), and a source index created by the previous major version (IllegalArgumentException, because split() hardcodes the output config). After that, every new IndexWriter or retried split into that path throws LockObtainFailedException 'Lock held by this virtual machine' until the JVM restarts, even after Directory.close() and System.gc() [exec]. Default FSDirectory and NativeFSLockFactory suffice and no scale is needed. One writer leaks per failed split. In the security probe (previous-major trigger) the failed output held nothing but write.lock, a pre-existing index there kept its previous commit, the source index was unchanged, and a split into fresh directories still worked in the same JVM [exec].
- Summary
- You split an index with MultiPassIndexSplitter.split, the split throws while it writes one of the output parts (for example the disk fills up, a file of the source index is corrupted, or the source index was created by the previous major version), and you then retry the split or open an IndexWriter on that output directory in the same JVM. You expect this to work once the cause is gone, because the failed split should close the IndexWriter it opened on the output. Instead split() calls close() only after addIndexes succeeds, so the writer, which the caller cannot reach, keeps the output's write lock, and every later IndexWriter or split into that directory throws LockObtainFailedException 'Lock held by this virtual machine' until the JVM restarts.
- Introduced
- 7d0d4ecc44c6acb60ac3cc0dbf9dc2c18d7497de (svn r824798, 2009-10-13, 'LUCENE-1959 Add MultiPassIndexSplitter.', by JIRA/GitHub sigram; JIRA LUCENE-1959 'Index Splitter', migrated as apache/lucene#3034, fix version 3.0; pre-GitHub, no PR) [read: the original split() (contrib/misc/.../MultiPassIndexSplitter.java:91-95 at 7d0d4ecc44c) opens the writer, calls addIndexes and calls close() only on success; git log -L over the block follows it to that commit through the renames, and none of the 16 later commits that touched it (API migrations and cosmetic changes, among them LUCENE-4246 8559eafd99e, 2014, which changed this close() line) added try/finally, rollback or try-with-resources]. Present since the class was created; not a regression [exec: the era-3 port reproduces on 3.0.0, the first release with the class]. (2009-10-13)
- First affected version
- 3.0.0 (tag 2009-11-25, the first release with the class) [exec: the era-3 public-API port fails: split throws on _0.fnm, then a new IndexWriter on the output gets LockObtainFailedException 'Lock obtain timed out'; the control, the same failure through a writer the program owns followed by rollback(), releases the lock]; not affected: 2.9.x and earlier, where the class does not exist [exec: the port does not compile against the 2.9.0 and 2.9.4 jars, and only the MultiPassIndexSplitter import and use fail; 2.9.1 and 2.9.2 by tag listing]
- Affected versions
- Every release that ships MultiPassIndexSplitter, 3.0.0 through 10.6.0, plus main ea57360912ed. [exec] public-API main() history ports (one source per API era: an output directory that throws IOException from createOutput like a full disk, then a new IndexWriter on that output, with a caller-owned-writer control) reproduce on 3.0.0, 3.6.2, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main, and the control passes in each; the finder's JUnit fails on main (-ea and -da), 10.5.2, 10.0.0 and 9.12.3; the independent reproduction's main() repro on main (-ea and -da), 10.5.2, 9.12.3 and 9.0.0; the intended-behaviour check's previous-major JUnit on main (-ea and -da), 10.5.2 and 9.12.3; a cross-model review's disk-full variant on main and 10.5.2. The symptom reads 'Lock obtain timed out' up to 5.0.0 and 'Lock held by this virtual machine' from 5.5.5 on. The one-test minimal repro fails on the 10.6.0 release jars (SHA-1 checked against Maven Central) with -ea and -da (2026-11-01). [read] every other release tag in the history clone (3.0.1, 3.1-3.6.1, 4.0.0-4.10.3, 5.1.0-5.5.4, 6.0.0-6.6.5, 7.0.0-7.7.2, 8.0.0-8.8.1, 9.1.0-9.12.2, 10.1.0-10.5.1) has the same unprotected block; 3.0.2, 3.0.3 and 8.8.2-8.11.3 are neither executed nor in the clone. Whether garbage collection releases the lock was executed only on main and 10.5.2 (it does not). Not fixed upstream: main ffa9d816eeb4 and branch_10x (2026-10-26) still have the block.
- 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
- semi-formal
- Upstream and prior reports
- none found (searched 2026-10-26, read-only; refreshed the same day): 160 query records: two scripted prior-report searches (103 queries over GitHub issues and PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, the JIRA archive and the web; 2,896 and 3,346 distinct hits), 31 GitHub GraphQL searches (apache/lucene, apache/lucene-solr, apache/solr, apache/lucenenet, elastic/elasticsearch, opensearch-project/OpenSearch, global), 14 manual API, JIRA, list and git checks (JQL over LUCENE and SOLR with 17 issue texts scanned, lists.apache.org, git history), 12 web searches, and the changed-file lists of all 407 open apache/lucene PRs. All 15 hits are other defects or context. Not fixed upstream: the newest upstream commit on the file is 54283150a816 (2026-06-14, #16255, typos-cli); main ffa9d816eeb4 and branch_10x still have the unprotected block; the three open PRs that touch the file (#13951, #14996, #16847) leave the writer lifecycle alone. Nearest items, not reports of this bug: #7044 / LUCENE-5982 (open since 2014: misc splitters should obtain write.lock on the source index; a different defect), #3993 / LUCENE-2919 (2011: the sibling PKIndexSplitter got exception-safe closing at creation; a precedent), commit 1f192290c5a (2012: an earlier resource-leak fix in this class, for the MultiReader, not the writer).
- Lucene commit tested
- main
645d4fe14a20(2026-11-01): the minimal test through gradle in a scratch worktree, with assertions on and off, fails on stock and passes with fix A and with fix B; the closest suite and the whole misc module pass with fix B. Release 10.6.0: the minimal test fails with assertions on and off (standalone, 2026-11-01). Earlier runs: the hunt's and the verifiers' tests on mainea57360912ed(assertions on and off), 10.5.2, 10.0.0, 9.12.3 and 9.0.0, and public-API history ports on 3.0.0, 3.6.2, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3 and 8.11.4 (2.9.0 and 2.9.4 lack the class).MultiPassIndexSplitter.javais the same blob at645d4fe14a20andea57360912ed; the split block is identical at 10.5.2 and 10.6.0, and the file is unchanged on upstream main2f424fd428da(2026-10-30).
- Finding record
a Lucene main checkout @ ea57360912ed: lucene/misc/src/java/org/apache/lucene/misc/index/MultiPassIndexSplitter.java:57 (@SuppressForbidden 'command line tool'), :60-69 (split javadoc, only '@throws IOException If there is a low-level I/O error'), :108-114 (writer opened at :109 with new IndexWriterConfig(null) and OpenMode.CREATE, addIndexes :113, close :114, no try/finally); lucene/misc/src/java/org/apache/lucene/misc/index/PKIndexSplitter.java:117 (try-with-resources) and :129 (addIndexes); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:137-139 (a writer holds the directory lock), :3228-3236 (validateMergeReader), :3290 (called up front), :3390-3391 (tragicEvent only for Error); lucene/core/src/java/org/apache/lucene/store/NativeFSLockFactory.java:68 (static LOCK_HELD), :119 ('Lock held by this virtual machine'), :145 and :212 (clearLockHeld, only when the lock is closed)
- The finding in 30 seconds
lucene/misc/src/java/org/apache/lucene/misc/index/MultiPassIndexSplitter.java:57, :60-69, :74-76, :108-114, :119-183 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:137-139, :1342, :3084-3086, :3228-3236, :3270-3273, :3290, :3390-3391 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/store/NativeFSLockFactory.java:68, :108, :119, :145-150, :200-213 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:173-188 (at 645d4fe14a20)
- Who is affected
lucene/misc/src/java/org/apache/lucene/misc/index/MultiPassIndexSplitter.java:119-183 (at 645d4fe14a20)
- Why it looks unintended
lucene/misc/src/java/org/apache/lucene/misc/index/MultiPassIndexSplitter.java:57, :68, :108-114 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:137-139, :3390-3391 (at 645d4fe14a20)lucene/misc/src/java/org/apache/lucene/misc/index/PKIndexSplitter.java:117-130 (at 645d4fe14a20)lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java:126, :132 (at 645d4fe14a20)lucene/misc/src/test/org/apache/lucene/misc/index/TestMultiPassIndexSplitter.java:69, :107 (at 645d4fe14a20)git log (history clone): bba6b01826d, 40f5948b3df, 1f192290c5a, 27d1272984f
- Suggested fix
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1017-1019, :1342-1368, :2566-2584, :6090-6106 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:102 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/IOUtils.java:114-136 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/store/NativeFSLockFactory.java:139-141 (at 645d4fe14a20)lucene/misc/src/java/org/apache/lucene/misc/index/PKIndexSplitter.java:117 (at 645d4fe14a20)
- History
git log -1 7d0d4ecc44c (history clone: Andrzej Bialecki, 2009-10-13)
- TestMultiPassIndexSplitterFailedSplit.java:
repro/LFV-554/src/org/apache/lucene/misc/index/TestMultiPassIndexSplitterFailedSplit.java(54 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.
assets/LFV-554/bug.png(485 KB)
