All findings · Lucene finding · LFV-554 · multipassindexsplitter-leaks-indexwriter-when-addindexes-fails

MultiPassIndexSplitter leaves the output write-locked when a split fails inside addIndexes

When MultiPassIndexSplitter.split fails inside addIndexes (a previous-major input, a corrupted source file, a full disk), it never closes the IndexWriter it opened, so that output directory refuses every new IndexWriter in the JVM until a restart. Every release since 3.0.0 that was run or read has it; the command-line tool is unaffected.

Summary

Priority
Low, rank 1085MultiPassIndexSplitter leaves its output write-locked after a failed addIndexes, only in the rarely used misc tool on same-JVM retry.
Impact
minor
Since
3.0.0 (LUCENE-1959, commit 7d0d4ecc44c, 2009-10-13)introduced 2009-10-13 · over 17 years
Affected
3.0.0-10.6.0 and main
Status
documented
Found by
semi-formal: an exception-edge resource scan of the jars, a fault-injection sweep, then a JUnit

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

split() closes the writer it opened only when addIndexes succeeds; there is no try/finally around it.

Background: the splitter, the write lock and addIndexes

  1. MultiPassIndexSplitter (module lucene-misc) splits one index into N parts. For each part it opens an IndexWriter on that part's output directory and copies the part's documents in with IndexWriter.addIndexes.
  2. An open IndexWriter holds its directory's write lock (write.lock). A second IndexWriter on the same directory gets LockObtainFailedException until the first one is closed or rolled back.
  3. With the default FSDirectory lock factory, the JVM also records every held lock path in one JVM-wide set; only closing that lock removes the path again.
  4. addIndexes can 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.
Diagram of the bug: MultiPassIndexSplitter.split opens an IndexWriter on out1, which takes out1's write.lock. addIndexes throws IllegalArgumentException (the input was created by major version 10, the output by 11), so w.close() is never reached. The writer stays open and unreachable, the lock path stays in the JVM-wide held-locks set, and a new IndexWriter on out1 (or a retried split) gets LockObtainFailedException "Lock held by this virtual machine". With the fix the writer is rolled back on the failure path and the lock is released.
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.

The finding, step by step

  1. You split a 2-document index into out1 and out2 with new MultiPassIndexSplitter().split(reader, new Directory[] {out1, out2}, false). The input was created by the previous major version (10; main is 11).
  2. split() opens an IndexWriter on out1 and calls addIndexes, 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.
  3. You expect the failed split to close the writer it opened, so a new IndexWriter on out1 (or a retried split with a different input) opens.
  4. Instead split() skipped its w.close(): the writer is still open and nobody can reach it, so new IndexWriter(out1, ...) throws LockObtainFailedException (on FSDirectory: "Lock held by this virtual machine"). On FSDirectory, closing the Directory, System.gc() and reopening do not help; only a JVM restart does.
  5. 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.

Who is affected

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.

The bug

All lines are at 645d4fe14a20; MultiPassIndexSplitter.java unless named.

  1. For each part, split() opens new IndexWriter(outputs[i], new IndexWriterConfig(null).setOpenMode(OpenMode.CREATE)) (:108-109), prints Writing part i ... (:110), calls w.addIndexes(...) (:113) and then w.close() (:114). No try/finally, no rollback(), no try-with-resources; w never leaves the method.
  2. Opening the writer takes the lock: "Opening an IndexWriter creates a lock file for the directory in use. Trying to open another IndexWriter on the same directory will lead to a LockObtainFailedException" (IndexWriter.java:137-139); close() "Closes all open resources and releases the write lock" (:1342).
  3. addIndexes(CodecReader...) catches only Error and makes it a tragic event (IndexWriter.java:3390-3391); an IOException, CorruptIndexException or IllegalArgumentException propagates with the writer still open. Its javadoc lists CorruptIndexException and IOException (:3270-3271) and points to addIndexes(Directory...), which names "disk full" as an expected exception (:3084-3086).
  4. The deterministic trigger: split() always builds its outputs with new IndexWriterConfig(null) (:109), the current major version, and validateMergeReader (IndexWriter.java:3228-3236, called first at :3290) rejects a source leaf with another created-version major. No merge thread starts.
  5. With FSDirectory, NativeFSLockFactory keeps held lock paths in private static final Set<String> LOCK_HELD (NativeFSLockFactory.java:68) and throws "Lock held by this virtual machine" for a path already in it (:119); only NativeFSLock.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.
  6. main() calls split() (:182) and declares throws Exception, so for the command-line tool the exception ends the JVM and the OS releases the lock. A program that calls main() and survives its exception is hit like any other caller (code reading).

Other triggers (executed)

  • Corrupted source: one flipped byte in the source's .fdt file makes split() throw CorruptIndexException: checksum failed from 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 createOutput throws IOException leaves 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 ConcurrentMergeScheduler thread, which also prints an uncaught MergeException to 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-line main().
  • Default vs opt-in. The class is in the optional lucene-misc module; inside it nothing else is needed. The default FSDirectory and NativeFSLockFactory give the JVM-lifetime lock; assertions make no difference.
  • Effect. One leaked open IndexWriter per 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 IOException at a single file-system event left the output locked in 18,654 of 18,866 split runs.

Minimal repro

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.

TestMultiPassIndexSplitterFailedSplit.java54 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.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());
      }
    }
  }
}
Command
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() throws IllegalArgumentException, and a new IndexWriter on out1 then opens.
Observed
new IndexWriter(out1, ...) throws LockObtainFailedException: lock instance already obtained ... lockName=write.lock (on FSDirectory: "Lock held by this virtual machine").

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT) and 10.6.0: fail with assertions on and off; main with fix A or B: passes
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# 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)

Why it looks unintended

  • split() creates the writer as a local variable and never hands it out, so only split() can close it; addIndexes leaves the writer open after an ordinary exception.
  • The sibling PKIndexSplitter has 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); the IndexWriter javadoc ties the lock to an open writer (IndexWriter.java:137-139), and addIndexes keeps the writer open after a non-Error exception (: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 upstream TestMultiPassIndexSplitter calls split() directly (testSplitRR, testSplitSeq), not main().
  • The sibling does it on purpose. PKIndexSplitter was added in LUCENE-2919 (bba6b01826d, 2011-06-17) and got failure-path closing the next day (40f5948b3df, Uwe Schindler, IOUtils.closeSafely(!success, w)); today try (IndexWriter w = new IndexWriter(target, config)) wraps the same addIndexes call (PKIndexSplitter.java:117-130, since #14913). IndexRearranger in 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.

Suggested fix

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.

B. Roll back on failure, close on success (validated by the checks) (recommended)

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 (an Error from rollback() is thrown instead, with the original suppressed into it, IOUtils.java:114-136); after an Error the tragic-event handling has already rolled the writer back (IndexWriter.java:6090-6106), and the second rollback() 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; TestMultiPassIndexSplitter passes together with it (2 classes, 3 tests, 0 failures); the comparison probe keeps the 3 documents; the whole :lucene:misc:test with 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 (plain FSDirectory; 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, forbiddenApisMain and forbiddenApisTest pass 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).

A. try-with-resources, as in PKIndexSplitter (validated by the checks)

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: commitOnClose defaults 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; TestMultiPassIndexSplitter passes 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 upstream TestMultiPassIndexSplitter passes 2 of 2.

Downstream note

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.

How it was found

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 IndexWriter acquired at MultiPassIndexSplitter.java:109 as live on the exception edge of IndexWriter.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 IOException at that event (seed 1531531531): the output lock leaked in 18,654 of 18,866 MultiPassIndexSplitter.split runs and in 0 of 19,020 PKIndexSplitter.split runs.
  • 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, lucene-solr, solr, lucenenet, Elasticsearch, OpenSearch, GitHub-wide)2026-10-26; re-checked 2026-11-01two 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 commitsno report; the same 14 issues name the class (none about a failed split or a held lock); no PR
Open PRs by touched file2026-10-26the 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-26issue 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, web2026-10-26JQL over LUCENE and SOLR (17 issues naming the class read in full), lists.apache.org (java-user, dev, general, solr-user, issues), 12 web searchesno report; the Lucene.NET port disposes the writer with a using statement (a port idiom, not a report)

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
2.9.0, 2.9.4 no classthe 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 affectedpublic-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 affectedport fails: "Lock held by this virtual machine"; the control passes
9.0.0, 9.12.3, 10.0.0, 10.5.2 affectedthe hunt's and verifiers' tests and ports fail (assertions on and off where run)
10.6.0 affectedthe minimal test fails with assertions on and off
main 645d4fe14a20 affectedthe minimal test fails through gradle with assertions on and off; passes with fix A or B
upstream main 2f424fd428da by readingthe file's newest commit is 54283150a816 (2026-06-14, cosmetic); the block is unchanged

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateException-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, highCalled the fault-injection trigger synthetic and the full-disk wording too strong; the wording was fixed.
independent reproducer realOwn 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, mediumWorks-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, highAdded 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, highRe-ran the reproducer (fails), the previous-major test with assertions off (fails) and the fix (passes); class minor.
re-runs, audit, minimisation, this doc confirmedRe-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, minorAn 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).

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

Finding record

LFV-554, snapshot of 2026-11-06

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 main ea57360912ed (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.java is the same blob at 645d4fe14a20 and ea57360912ed; the split block is identical at 10.5.2 and 10.6.0, and the file is unchanged on upstream main 2f424fd428da (2026-10-30).

Lucene and upstream sources

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)

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.