All findings · Lucene finding · LFV-558 · directorytaxonomywriter-ctor-leaks-indexwriter-on-failure

DirectoryTaxonomyWriter's constructor leaves the taxonomy write-locked when a file read fails after it opened its IndexWriter

If new DirectoryTaxonomyWriter(dir) hits a file read error (not a flush failure such as a full disk) after opening its internal IndexWriter, it throws without closing that writer. You get no object to close, so the taxonomy stays write-locked: every later new DirectoryTaxonomyWriter(dir) in that JVM fails with LockObtainFailedException, in every release since 3.4.0.

Summary

Priority
Low, rank 1084The DirectoryTaxonomyWriter constructor leaks the write lock only when an I/O error hits a short construction window.
Impact
crash
Since
3.4.0 (2011, the facet module's first release); the existing-taxonomy path since 4.1.0introduced 2011-06-29 · over 15 years
Affected
3.4.0 to 10.6.0, and main
Status
documented
Found by
a static exception-path scan of the compiled jars, then fault injection and a JUnit repro

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

The finding in 30 seconds

The constructor takes the write lock first, then does more I/O, and has no cleanup if that I/O fails.

Background: the taxonomy writer and its lock

  1. Taxonomy facets keep their category labels in a separate Lucene index, the taxonomy. DirectoryTaxonomyWriter writes it through an internal IndexWriter that it creates in its constructor and closes in its own close().
  2. An IndexWriter holds the directory's write.lock until it is closed or rolled back. With FSDirectory's default NativeFSLockFactory, the JVM also records held locks in a static set, so a second writer on the same directory in the same JVM fails at once with LockObtainFailedException.
  3. In Java, when a constructor throws, the caller receives no object. Only the constructor itself can release what it already acquired.
Diagram of the bug: new DirectoryTaxonomyWriter(dir) opens an internal IndexWriter, which takes write.lock and records it in the JVM's held-lock set. The constructor then reads the latest commit; that read throws an IOException, and the constructor throws it without closing the writer. The caller has no object to close. A retry, new DirectoryTaxonomyWriter(dir), fails with LockObtainFailedException because the JVM still records the lock as held. With the fix, the constructor rolls the writer back before rethrowing, and the retry opens.
The constructor opens the writer and takes the lock, the commit read fails, the constructor throws with no object to close, and the JVM-wide lock record blocks every retry. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Open an existing taxonomy with new DirectoryTaxonomyWriter(dir). The constructor first opens an internal IndexWriter, which takes the directory's write.lock, then reads the latest commit to get the taxonomy's epoch.
  2. That read fails once: one IOException while opening segments_N (injected in the repro; in practice, say, "Too many open files"). The constructor throws it, as it should.
  3. You retry once the error is gone and expect it to work: a constructor that throws gives you nothing to close, so it must release what it took, as IndexWriter's own constructor does.
  4. Instead the retry, and all 200 retries in a probe, fail with LockObtainFailedException ("Lock held by this virtual machine" on FSDirectory). That JVM keeps the lock recorded as held until it exits; other processes are locked out only until its garbage collector closes the unreachable writer's lock channel.
  5. Nothing is lost: readers still see every committed category with its ordinal, and after a JVM restart (default lock factory) the writer opens. A new taxonomy fails the same way when opening the reader for its root category fails.

Who is affected

Applications using taxonomy facets with the default DirectoryTaxonomyWriter, every release since 3.4.0, when a read or open error (not a full disk) hits the constructor after its writer opened and they retry in the same process. Writes to that taxonomy from that JVM stay blocked until it exits; no data was lost in any tested scenario.

Mechanism, terms and numbers590 words

DirectoryTaxonomyWriter's constructor opens its internal IndexWriter first (DirectoryTaxonomyWriter.java:145), which takes the taxonomy directory's write.lock, and only then reads the latest commit of an existing taxonomy (:158) or, for a new one, adds the root category through a near-real-time reader (:185). Nothing after :145 is inside a try, so when one of those reads throws an IOException the constructor throws with the writer still open. The caller gets no object, so neither close() nor rollback(), the only releases, can ever run, and NativeFSLockFactory keeps the lock path in a static JVM-wide set until the lock is closed. Every later new DirectoryTaxonomyWriter(dir) in that JVM then fails with LockObtainFailedException ("Lock held by this virtual machine"), other processes are locked out only until that JVM's garbage collector closes the leaked lock channel, and with SimpleFSLockFactory the stale write.lock survives even a normal exit. No data was lost or corrupted in any tested scenario, and readers keep working. IndexWriter's own constructor releases its lock on any failure; this one never did. A history probe reproduced it on 20 releases from 3.4.0 to 10.5.2 and on main; 10.6.0 and upstream main have the same file. Low priority: it needs an I/O error inside a short window and an in-process retry.

Mechanism (Lucene main 645d4fe14a20)

// DirectoryTaxonomyWriter(Directory, OpenMode, TaxonomyWriterCache), condensed
indexWriter = openIndexWriter(dir, config);                // :145  new IndexWriter: takes write.lock
...
if (DirectoryReader.indexExists(directory) == false) {     // :154
  indexEpoch = 1;
} else {
  SegmentInfos infos = SegmentInfos.readLatestCommit(dir); // :158  path A: IOException -> writer leaks
  ...
}
...
if (nextID.get() == 0) {
  addCategory(new FacetLabel());                           // :185  path B: NRT reader open -> writer leaks
}
// no try/catch anywhere after :145
  1. openIndexWriter (:215-218) returns new IndexWriter(directory, config), which obtains write.lock (IndexWriter.java:954). If IndexWriter's own constructor fails, it releases the lock itself (IndexWriter.java:1202-1208, "init: hit exception on init; releasing write lock").
  2. Path A, existing taxonomy: SegmentInfos.readLatestCommit(dir) (:158) reads segments_N again, under the lock, for the index.epoch entry. An IOException there propagates.
  3. Path B, new taxonomy: addCategory(new FacetLabel()) (:185) adds the root document (:468), then getTaxoArrays() (:475, :665-683) calls initReaderManager(), which opens a near-real-time reader (new ReaderManager(indexWriter, false, false), :252). An IOException while that reader opens a segment file propagates; a reader manager already built leaks too.
  4. The exception leaves the constructor. The only releases, close() (:288-294) and rollback() (:861-865), are instance methods of an object the caller never got.
  5. NativeFSLockFactory adds the lock path to its static LOCK_HELD set when it obtains the lock (NativeFSLockFactory.java:68, :108) and removes it only when the lock is closed (:145-150); a later attempt throws "Lock held by this virtual machine" (:119). Garbage collection does not help in that JVM (an independent reproducer's probe: still held after five System.gc() rounds); it only closes the leaked file channel, which lets other processes in (see Who is affected). There is no unlock API (removed in 5.0, LUCENE-6060).

Kept out of the claim (by reading only, not executed): the indexExists edge (:154); a NumberFormatException at :165, which needs a malformed index.epoch entry (crafted files or misuse); the -ea-only merge-policy assert at :148. Other steps after :145 could throw too (getDocStats() at :174 calls ensureOpen(); any Error); only the two paths above were executed, and the fix's catch (Throwable) covers them all.

Exposure and likelihood353 words
  • Who. Applications that use taxonomy facets (the opt-in facet module) with DirectoryTaxonomyWriter: its default constructor, IndexWriterConfig, codec and FSDirectory suffice. In-tree, the facet demo examples and the benchmark's taxonomy tasks construct it.
  • When. A non-aborting I/O error in the short window after the constructor opened its writer (the commit read of an existing taxonomy, or the reader open for the root category of a new one), and a retry in the same process. Likelihood is rated rare: the searches found no public report of it since 2011.
  • What goes wrong (executed probe on main with assertions on and off, 10.5.2 and 9.12.3): every later writer open on that taxonomy in the failed JVM fails fast with LockObtainFailedException until that JVM exits; 200 retries all failed. Another process is locked out only until that JVM's garbage collector closes the leaked lock channel: NativeFSLockFactory opens the lock with FileChannel.open (NativeFSLockFactory.java:132), and OpenJDK 25 registers a Cleaner that closes an unreachable channel's descriptor. A cross-process probe showed it: another JVM was refused ("Lock held by another program"), then after one System.gc() in the failed JVM it opened a writer while the failed JVM was still alive and that JVM's own retry still failed. When that collection happens is up to the JVM. With the non-default SimpleFSLockFactory, write.lock survives even a normal exit and must be deleted by hand. The message says the lock is held, not why, so it looks like a second writer the application does not have.
  • Not a trigger: a disk-full error while the root category is flushed. IndexWriter treats that as tragic and closes itself, so nothing leaks (executed).
  • What it does not do (executed, in the tested fault scenarios): nothing was lost or corrupted. Taxonomy readers return the exact committed label-to-ordinal map, the committed files stay byte-identical, other taxonomies and the main index are unaffected, and after a restart (default lock factory) every ordinal is preserved. The leak is bounded: only the first failure on a taxonomy leaks, one unreachable IndexWriter and its lock descriptor, until garbage collection reclaims them; retries fail before they acquire anything.

Minimal repro

One JUnit test, public API only: a FilterDirectory over ByteBuffersDirectory throws one IOException when the constructor itself (not IndexWriter) opens segments_N. The test checks that the constructor throws, then opens a second DirectoryTaxonomyWriter.

TestDirectoryTaxonomyWriterCtorFailure.java59 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.facet.taxonomy.directory;

import java.io.IOException;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FilterDirectory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexInput;
import org.apache.lucene.store.LockObtainFailedException;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDirectoryTaxonomyWriterCtorFailure extends LuceneTestCase {

  private volatile boolean failNextCommitRead;

  public void testWriterOpensAfterConstructorFailedReadingCommit() throws Exception {
    try (Directory dir =
        new FilterDirectory(new ByteBuffersDirectory()) {
          @Override
          public IndexInput openInput(String name, IOContext context) throws IOException {
            if (failNextCommitRead
                && name.startsWith("segments_")
                && StackWalker.getInstance()
                    .walk(s -> s.noneMatch(f -> f.getClassName().equals(IndexWriter.class.getName())))) {
              failNextCommitRead = false;
              throw new IOException("simulated I/O error reading " + name);
            }
            return super.openInput(name, context);
          }
        }) {
      new DirectoryTaxonomyWriter(dir).close();
      failNextCommitRead = true;
      expectThrows(IOException.class, () -> new DirectoryTaxonomyWriter(dir));
      // expected: a new writer opens after the failed constructor; actual: LockObtainFailedException
      try {
        new DirectoryTaxonomyWriter(dir).close();
      } catch (LockObtainFailedException e) {
        fail("the failed constructor left the taxonomy write-locked: " + e);
      }
    }
  }
}
Command
cp $REPRO/TestDirectoryTaxonomyWriterCtorFailure.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/taxonomy/directory/
cd $LUCENE && ./gradlew :lucene:facet:test --tests org.apache.lucene.facet.taxonomy.directory.TestDirectoryTaxonomyWriterCtorFailure -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false
Expected
After the injected read error, a second new DirectoryTaxonomyWriter(dir) opens.
Observed
The second constructor throws LockObtainFailedException: lock instance already obtained ... lockName=write.lock (assertions on and off).

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

More files, environment, full output246 words

One test class, one method, single thread, deterministic. The fault fires once, only for a segments_N open with no IndexWriter frame on the stack, which is the constructor's own readLatestCommit (DirectoryTaxonomyWriter.java:158) after the writer opened; a failure inside IndexWriter's constructor would release the lock by itself. expectThrows checks that the constructor throws an IOException; on its own it would also accept a LockObtainFailedException, so a probe repeating the same steps confirmed that the first exception is the injected one, fired once, from :158.

ByteBuffersDirectory uses SingleInstanceLockFactory, so the message is "lock instance already obtained"; on FSDirectory with the default NativeFSLockFactory the same retry fails with "Lock held by this virtual machine" (the independent checks' tests).

This same test is the suggested regression test: it fails on stock main and passes with the fix.

Environment

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

Observed output

# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (module prefixes and environment lines cut)
TestDirectoryTaxonomyWriterCtorFailure > testWriterOpensAfterConstructorFailedReadingCommit FAILED
    java.lang.AssertionError: the failed constructor left the taxonomy write-locked: org.apache.lucene.store.LockObtainFailedException: lock instance already obtained: (dir=ByteBuffersDirectory@3225a843 lockFactory=org.apache.lucene.store.SingleInstanceLockFactory@31547513, lockName=write.lock)
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:ED5CA87A0D7F0AAE]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.apache.lucene.facet.taxonomy.directory.TestDirectoryTaxonomyWriterCtorFailure.testWriterOpensAfterConstructorFailedReadingCommit(TestDirectoryTaxonomyWriterCtorFailure.java:55)

1 test completed, 1 failed

# the same failure and message with -Ptests.asserts=false
# FSDirectory with the default NativeFSLockFactory (an independent check's test on main, path shortened):
#   org.apache.lucene.store.LockObtainFailedException: Lock held by this virtual machine: $TMP/taxo-fs-001/write.lock
#   java.lang.RuntimeException: file handle leaks: [FileChannel($TMP/taxo-fs-001/write.lock)]

Why it looks unintended

  • The javadoc documents LockObtainFailedException only "if the taxonomy is locked by another writer"; here the holder is a writer the caller never received.
  • IndexWriter's own constructor releases its lock on any failure; upstream fixed that same symptom there in LUCENE-2476.
  • On LUCENE-3596 (2011), Doron Cohen noted that a check made after this writer opens must close it before throwing.
The full argument209 words
  • The contract. Constructor javadoc: "@throws LockObtainFailedException if the taxonomy is locked by another writer" (DirectoryTaxonomyWriter.java:137). The openIndexWriter javadoc makes the taxonomy writer the owner of the IndexWriter: "the instance this method returns will be closed upon calling to close()" (:206); a failed constructor leaves no object to call close() on.
  • The codebase convention. IndexWriter's constructor catches any Throwable and releases its lock (IndexWriter.java:1202-1208), added after the same symptom in IndexWriter (LUCENE-2476, #3550). In the same module, LUCENE-8244 added a failure-path close to SearcherTaxonomyManager.
  • The principle, stated for this class (as Doron Cohen pointed out on LUCENE-3596, #4670, 2011): a merge-policy check done "after the index was already opened ... must be closed prior to throwing that exception". That check became the :148 assert; the later I/O steps never got the cleanup.
  • History. LUCENE-4532 (#5598, 2012) moved the commit read after openIndexWriter so that it runs under the lock; that explains the order, not why the lock should outlive a failed read, and the issue does not discuss a failing read. No commit ever added or removed cleanup in this constructor (126 release tags checked).
  • Tests. No upstream taxonomy test injects I/O failures at all, so this path was never exercised.
The devil's-advocate check193 words

The strongest "works as intended" case (built by a cross-model reviewer, OpenAI GPT-5.6, and by a devil's-advocate verifier): the class is @lucene.experimental (:76); @throws IOException if another error occurred promises no exception safety; the openIndexWriter javadoc promises closing only through close(); LUCENE-4532 put the read under the lock on purpose; every repro injects its error; and after an I/O error one should restart.

Why it fails: @lucene.experimental is about API stability, not about leaking an unreachable writer. The caller of a failed constructor has no object to close and no unlock API, so only the constructor can release the lock; IndexWriter's constructor shows the expected contract, and LUCENE-3596 states it for this class. An IOException from Directory.openInput is an ordinary declared failure, reached in one check through a plain FSDirectory throwing "Too many open files". Restarting is a workaround, and with SimpleFSLockFactory it does not even work. Both the reviewer and the verifier rated the intended-behaviour argument weak; a later read-only fact check by the same model family rated it moderate, because a subclass may supply its own writer through openIndexWriter and every trigger was injected (see Suggested fix).

Suggested fix

Recommended A. try/catch (Throwable) after openIndexWriter: close the reader manager if opened, roll back the writer, rethrow

Wrap the rest of the constructor in try/catch (Throwable t): close the reader manager if it was opened, roll back (not close) the IndexWriter, rethrow t. The repro passes with assertions on and off, the facet module's 307 tests run with 0 failures (7 skipped), and an independent test of both trigger paths passes.

    indexWriter = openIndexWriter(dir, config);
    try {
      // ... the existing constructor body, unchanged ...
    } catch (Throwable t) {
      IOUtils.closeWhileSuppressingExceptions(
          t,
          () -> {
            if (initializedReaderManager) {
              readerManager.close();
            }
          },
          indexWriter::rollback);
      throw t;
    }
All options and trade-offs2 options ยท 539 words

Option A was run through gradle in a scratch worktree of 645d4fe14a20; B is the shortcut a reviewer may suggest, and why it is worse. Moving the commit read back before openIndexWriter is not a fix: LUCENE-4532 put it under the lock on purpose, and the new-taxonomy path needs the open writer anyway.

A. try/catch (Throwable) after openIndexWriter: close the reader manager if opened, roll back the writer, rethrow (validated by the checks) (recommended)

In the constructor (DirectoryTaxonomyWriter.java:145-195): the existing body moves, unchanged and re-indented, into the try; ignoring whitespace the change is +16/-1, the IOUtils import included.

    indexWriter = openIndexWriter(dir, config);
    try {
      // ... the existing constructor body, unchanged ...
    } catch (Throwable t) {
      IOUtils.closeWhileSuppressingExceptions(
          t,
          () -> {
            if (initializedReaderManager) {
              readerManager.close();
            }
          },
          indexWriter::rollback);
      throw t;
    }

Trade-offs

  • Roll back, not close: IndexWriter.close() commits by default (IndexWriter.java:1363-1369), so on the new-taxonomy path it would commit the root category the failed constructor had buffered. rollback() also works on a writer that already closed itself after a tragic error (IndexWriter.java:2576-2582).
  • No virtual call: it does not call closeResources(), a documented subclass hook, on a partially constructed object. Both points came from the cross-model review of the first fix draft.
  • The original exception is rethrown with cleanup exceptions suppressed into it; only if a cleanup step throws an Error while the original is not one is that Error thrown instead, with the original suppressed into it (IOUtils.java:116-136), as in IndexWriter's own constructor (IndexWriter.java:1206). The cache is not closed: the default LruTaxonomyWriterCache is heap-only (its close() only clears a map), and a cache the caller passed in stays with the caller.
  • A subclass that overrides openIndexWriter to supply its own IndexWriter gets that writer rolled back when the constructor fails. The openIndexWriter javadoc already makes the taxonomy writer its owner (:206), but a reviewer may want that stated.
  • The helper exists from 10.4.0 on; a 9.x backport would use IOUtils.closeWhileHandlingException in the same place (not run).

What the checks validated

  • Stock 645d4fe14a20: the repro fails with assertions on and off.
  • With the fix: the repro and TestDirectoryTaxonomyWriter pass with assertions on and off (16 tests, 0 failures, 1 skipped: testHugeLabel is @Nightly); the whole :lucene:facet:test passes with assertions on (40 suites, 307 tests, 0 failures, 7 skipped).
  • The independent reproducer's test, unchanged, passes 4 of 4: the existing taxonomy on ByteBuffersDirectory and on FSDirectory, the new taxonomy whose root-category reader open fails, and the control; no leaked-file report. Its 3 bug tests fail on stock code.
  • ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass; checkGoogleJavaFormat passes for the fixed class and, once ./gradlew tidy rewraps two lines of the repro test (line breaks only), for the test too.

B. Call the taxonomy writer's own close() or rollback() in the catch (not recommended, not run)

Shorter, but close() commits first (DirectoryTaxonomyWriter.java:288-294), and when they complete normally both close() and rollback() (:861-865) end in the overridable closeResources() (:296-299, :308), which runs subclass code on a partially built object.

Trade-offs

  • Reads naturally, but can commit a half-built taxonomy and runs subclass code too early.

Downstream note

A read-only code search found no use of DirectoryTaxonomyWriter in Solr, Elasticsearch or OpenSearch, so the exposure is mainly applications that use Lucene's taxonomy facets directly.

How it was found

A static scan of the compiled Lucene jars, which follows exception paths and tracks acquired resources, flagged the writer this constructor holds on its I/O exception edges. A fault-injection sweep that fails one file operation at a time showed the lock leak, and a JUnit test confirmed it.

The method in full128 words

Method: semi-formal static analysis (a custom bytecode abstract interpretation), from a sweep over Lucene's failure paths.

  • The analysis interprets the bytecode of the core, codecs, facet, suggest, misc and replicator jars (3,388 classes, 24,094 methods) with explicit exception edges, a live set of acquired resources and interprocedural summaries. Its vacuity check flagged all 7 planted leaks and none of 8 clean idioms.
  • It reported that the IndexWriter from openIndexWriter (:145) is still held on the exception edges of DirectoryReader.indexExists (:154), SegmentInfos.readLatestCommit (:158) and addCategory (:185).
  • A single-fault sweep with the test framework's MockDirectoryWrapper (2,917 fault events on an existing taxonomy, 5,390 on an empty one) showed lock leaks in both cases; a JUnit test on the stock jars confirmed it.

Prior reports, history and checks

Prior reports

No prior report: about 160 queries over GitHub issues and PRs, the JIRA archive, the dev@, java-user@, issues@, commits@ and builds@ lists, downstream projects and the web (2026-10-26; refreshed 2026-11-01), plus the changed files of all open PRs. The nearest item, #7669, is a different stream leak in the same class.

Searches4 searches

Verdict new. The closest items are #7669 / LUCENE-6611 ("Potential resource leakage in DirectoryTaxonomyWriter.java", an unclosed stream in DiskOrdinalMap.getMap, fixed in 2016) and the 2011 design remark on #4670 / LUCENE-3596 about a check that was never added. Precedents of the same bug class, fixed elsewhere: #3550 / LUCENE-2476 (IndexWriter's constructor) and LUCENE-8244 (SearcherTaxonomyManager). No open PR adds cleanup to the constructor.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, apache/lucene-solr, apache/lucenenet, downstream servers, all of GitHub)2026-10-26; refreshed 2026-11-01two scripted prior-report search runs (74 and 26 queries; 410 and 328 hits, 0 high) plus manual searches: class and method names, LockObtainFailedException, "taxonomy constructor lock", cleanup idioms; refresh: DirectoryTaxonomyWriter updated since 2026-10-20, "DirectoryTaxonomyWriter constructor", "taxonomy writer lock", "LockObtainFailedException taxonomy", "taxonomy constructor leak"#7669 / #7720 (another leak in the same class, fixed), #4670 (design remark), #5598 (moved the read under the lock), #10580 (unrelated); none reports this bug
Open PRs by touched file2026-10-26; refreshed 2026-11-01the changed files of all 407 open PRs; refresh: the 58 open PRs updated since 2026-10-26#16847, #13951 and #12862 touch the file; none adds constructor cleanup; no refreshed PR touches it
Mailing lists and JIRA2026-10-26dev@, java-user@, issues@, commits@ and builds@ (direct and scripted searches; CI failure mails read); full comments of 8 JIRA issuesthe 2015 dev@ Coverity thread (only the nextID update); CI failures of other tests; no report
Web and git history2026-10-2610 web searches; git log --follow over the file (108 commits, 2011-2024)no report; no commit ever changed cleanup in the constructor

History

The new-taxonomy path dates from the facet module's first commit (2011, LUCENE-3079; release 3.4.0), the existing-taxonomy path from LUCENE-4532 (2012; release 4.1.0). A probe reproduced it on 20 releases from 3.4.0 to 10.5.2 and on main; 10.6.0 and upstream main have the same file.

Versions tested7 rows

Path B (new taxonomy) was there from the start: branch_3x d4e1a420d525 "LUCENE-3079: add faceting module" (Shai Erera, 2011-06-29; trunk port 4291f52c70d7; GitHub #4152). Path A (existing taxonomy) came with LUCENE-4532 (branch_4x b9bfc1e6b768, trunk 2e896c4af6d9, 2012-11-07; #5598), which moved the commit read from before openIndexWriter to after it. Neither went through a PR (both predate GitHub).

The probe: public API, FSDirectory with the default lock factory, one injected IOException at Directory.openInput; two controls (a fault inside IndexWriter's own constructor, and no fault) pass in every run. Across the 126 release tags from 3.4.0 to 10.5.2 in the history clone, no constructor has a try. The old unlock(Directory) (until 4.10.4) did not help in the same JVM. The lock message changed in 5.x ("Lock obtain timed out" before, "Lock held by this virtual machine" after); the exception class did not.

Versions and branches executed, with results
Version or commitVerdictResult
3.3 not affectedno facet module, the API does not exist
3.4.0, 3.5.0, 3.6.2, 4.0.0 partlypath B (new taxonomy) leaks; path A does not (no read after the writer opens, or the read comes before it)
4.1.0 affectedboth paths leak (LUCENE-4532), with assertions on and off
4.5.1, 4.6.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 10.0.0 affectedboth paths leak (release jars)
9.12.3, 10.5.2 affectedboth paths leak, assertions on and off; the hunt's and verifiers' JUnit tests fail
10.6.0, branch_10_6, branch_11x, upstream main 2f424fd428d by readingthe same file as 645d4fe14a20
main 645d4fe14a20 affectedthe minimal test fails through gradle with assertions on and off; passes with the fix

Independent checks

Confirmed with high confidence: the finder, an independent reproducer, a devil's-advocate verifier and an adjudication each reproduced it; cross-model reviews (OpenAI GPT-5.6, read-only) rated it real; this doc re-ran it through gradle and validated the fix.

Checks and verdicts415 words

Everyone agrees on the root cause, that only non-aborting read/open errors trigger it, and that the harm is a write lock-out, not data loss.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateStatic scan and fault sweep; its JUnit (FSDirectory, injected read errors on both paths) fails 2 of 2 on main (assertions on and off), 10.5.2 and 9.12.3, and passes with the fix; upstream TestDirectoryTaxonomyWriter passes 14 of 14 with the fix.
cross-model review, refute lens (OpenAI GPT-5.6, read-only) real, highLowered the impact class to crash and asked the fix to roll back instead of close and to skip the overridable closeResources(); both adopted.
independent reproducer realOwn test from the claim alone, a plain FilterDirectory: 3 bug tests fail on main (assertions on and off), 10.5.2 and 9.12.3; the IndexWriter-constructor control passes; the lock survives garbage collection; the fix passes 4 of 4.
cross-model review, intended lens (OpenAI GPT-5.6, read-only) real, highIntended-behaviour argument weak; the review's own plain-FSDirectory check fails on main, 10.5.2 and 9.12.3 and passes with the fix.
devil's-advocate verifier real (high on intent)Rejected the by-design case; a plain FSDirectory throwing "Too many open files" leaks on main, 10.5.2 and 9.12.3; disk full during the root-category flush does not.
adjudication confirmed, highRe-ran the reproducer's test on main with assertions on and off: the 3 bug tests fail, the control passes.
history, security screen, audit, minimization 3.4.0 first affected; security none; confirmed; verified20 releases plus main run; a security probe on main, 10.5.2 and 9.12.3 (bounded leak, data intact); the credibility audit's re-runs; the one-test repro on 645d4fe14a20 (3 seeds with assertions on, 1 off).
fact checks (2026-11-01) reproduced; corrections adoptedRe-ran the repro (standalone and through gradle, assertions on and off), the independent reproducer's test and the fix; a probe showed the injected error fires once at :158; a cross-process probe showed that another JVM can open a writer once the failed JVM has run garbage collection. A second, read-only fact check (OpenAI GPT-5.6) raised the same point from the JDK source.
this doc (2026-11-01) reproduced, fix validatedGradle on 645d4fe14a20: the minimal test fails with assertions on and off; with the fix it passes, the facet module's 307 tests run with 0 failures (7 skipped), the independent reproducer's test passes 4 of 4 (both paths), and lint passes once the test is formatted.

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

Finding record

LFV-558, snapshot of 2026-11-06

Registry title
DirectoryTaxonomyWriter's constructor never closes or rolls back the IndexWriter it has opened when a later step of the constructor fails with an I/O error, so the taxonomy stays write-locked and every later DirectoryTaxonomyWriter on it in the same JVM fails with LockObtainFailedException
Area
facet / taxonomy writer (DirectoryTaxonomyWriter construction, failure cleanup)
Priority
Low, rank 1084: The DirectoryTaxonomyWriter constructor leaks the write lock only when an I/O error hits a short construction window.
Impact class
crash
Impact note
Applications that use taxonomy facets with the default DirectoryTaxonomyWriter (default constructor, IndexWriterConfig and codec, FSDirectory with its default NativeFSLockFactory) are hit when one constructor call fails with a non-aborting I/O error after it has opened its internal IndexWriter (while it reads the latest commit of an existing taxonomy, or opens the near-real-time reader that adds the root category of a new one; for example running out of file handles or a transient read error on a network filesystem) and they then retry in the same process: every later new DirectoryTaxonomyWriter on that directory throws LockObtainFailedException 'Lock held by this virtual machine' until the JVM exits, other processes cannot open a writer on it while that JVM lives, and with the non-default SimpleFSLockFactory the write.lock file survives even a normal exit and must be deleted by hand [exec]; taxonomy readers, the committed taxonomy and its ordinals, the main index and other taxonomies are unaffected, the leak is bounded (one IndexWriter and one lock descriptor per failed path; 200 retries added nothing) [exec], a disk-full error during the root-category flush does not leak (IndexWriter's tragic-event path closes the writer) [exec], and no scale is needed.
Summary
You open a taxonomy with new DirectoryTaxonomyWriter(dir) and the constructor throws an IOException, because a file read failed after it had already opened its internal IndexWriter (while reading the latest commit of an existing taxonomy, or while opening a reader to add the root category of a new one); once the error has passed you try again. You expect the retry to work, because a constructor that throws hands the caller nothing to close and so must release what it acquired, as IndexWriter's own constructor does. Instead the constructor has no cleanup after openIndexWriter, so the unreachable IndexWriter keeps the taxonomy's write lock and every later DirectoryTaxonomyWriter on that directory in the same JVM fails with LockObtainFailedException 'Lock held by this virtual machine'.
Introduced
d4e1a420d525ecf022ee8c3594f3683005c5cc85 (branch_3x svn r1141060, 2011-06-29, 'LUCENE-3079: add faceting module', GitHub apache/lucene#4152; no PR, it predates GitHub; trunk port 4291f52c70d7b6324456663cc3a7ec0df855ccb7 the same day) [read: this commit added the class, then named LuceneTaxonomyWriter; its constructor opens the IndexWriter and then, for an empty taxonomy, adds the root category through a near-real-time reader open with no try/catch; exec: this path, B (new taxonomy), leaks on 3.4.0, the first release]. The existing-taxonomy path, A, came with LUCENE-4532 (branch_4x b9bfc1e6b768f2ee4ea4ede5a9741d1e0d668232, trunk 2e896c4af6d9b8adc18bd9200fc2a23ac82f7f0f, 2012-11-07, GitHub apache/lucene#5598), which moved the commit-data read from before openIndexWriter to after it, so that it runs under the lock; that issue does not discuss a failing read [read; exec: 4.0.0 does not leak on path A, 4.1.0 does]. Not bisected at commit level; release brackets executed. No version of the constructor ever had a try block (code-identity check over 126 release tags, 3.4.0 to 10.5.2). (2011-06-29 (d4e1a420d525, path B, new taxonomy); 2012-11-07 (LUCENE-4532, path A, existing taxonomy))
First affected version
3.4.0 (2011-09-14, the first release with the class) [exec]: path B (new taxonomy) leaks there. Path A (existing taxonomy) leaks from 4.1.0 (2013-01-21) [exec]; 3.4.0 and 3.5.0 read no file after the writer opens, and 3.6.2 and 4.0.0 read the commit before opening the writer, so path A does not leak there [exec]. No earlier release has the API: 3.3 has no facet module.
Affected versions
Every release from 3.4.0 to 10.6.0 (10.6.0 by reading), plus main ea57360912ed. [exec] A public-API main() history probe (FSDirectory with the default lock factory, one IOException injected at Directory.openInput, selected by call stack; scenario A existing taxonomy, scenario B new taxonomy, control C a fault inside IndexWriter's own constructor, control D no fault) reproduces on 20 releases: 3.4.0, 3.5.0, 3.6.2, 4.0.0 (path B only), 4.1.0, 4.5.1, 4.6.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2, and on main (-ea; also -da on 3.4.0, 4.0.0, 4.1.0, 9.12.3, 10.5.2 and main); both controls pass in every run. The finder's JUnit fails on main (-ea and -da), 10.5.2 and 9.12.3; the independent reproduction's on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's plain-FSDirectory test on main (-da), 10.5.2 and 9.12.3; a cross-model review's plain-FSDirectory main() check on main (-ea and -da), 10.5.2 and 9.12.3. The JUnit tests need the 9.x+ test framework, so 9.0.0 and older ran only through the main() probe. [read] A code-identity scan of the 126 release tags from 3.4.0 to 10.5.2 in the history clone finds no try block in the constructor in any tag, path B in all 126 and path A in the 120 tags from 4.1.0 on (the 8.9.0-8.11.x tags are missing from the clone; 8.11.4 was executed). Upstream main a95c754ee003 (2026-10-26), branch_10x and branch_10_6 have the same file blob as the tested copy.
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: the finder's two scripted prior-report searches, offline and online, 156 hits, and a later search): two further scripted prior-report searches (74 queries over GitHub issues and PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, JIRA and the web, 410 distinct hits, 0 high; and a lists-and-JIRA run, 26 queries, 328 hits, 0 high), manual GitHub searches in apache/lucene, apache/lucene-solr, apache/lucenenet, three downstream search servers and all of GitHub, lists.apache.org searches of dev@, builds@ and issues@, JIRA REST reads of 8 issues with all comments, 10 web searches, a scan of the changed files of all 407 open apache/lucene PRs, and git history (no commit on the file ever added or removed constructor cleanup). Not fixed upstream: DirectoryTaxonomyWriter.java on upstream main a95c754ee003 (2026-10-26), branch_10x and branch_10_6 has the same blob as the tested copy (last change #12337, 2024-02-08); the only open PR that edits the constructor, #16847, adds no cleanup (#13951 and #12862 touch other lines). Nearest items, not reports of this bug: #7669 / LUCENE-6611 and #7720 / LUCENE-6662 (an unclosed DataInputStream in DiskOrdinalMap.getMap of the same class, fixed in 2016); the 2015 dev@ thread 'Coverity scan results of Lucene' (flagged only the non-atomic nextID update); #4670 / LUCENE-3596 (a 2011 design remark that a proposed, never added, post-open configuration check would have to close the writer before throwing); #5598 / LUCENE-4532 (moved the commit read under the lock; failure not discussed); #4653, #4630, #5133, #6036 and #5949 (other DirectoryTaxonomyWriter behaviour); #7869 / LUCENE-6811 (the same symptom string from another class). Precedents of the same bug class, fixed elsewhere: #3550 / LUCENE-2476 (IndexWriter's own constructor kept write.lock after an exception) and LUCENE-8244 (SearcherTaxonomyManager leaked a reader when refresh failed). Gaps: keyword search only; private reports to security@lucene.apache.org are not visible.
Lucene commit tested
main 645d4fe14a20 (2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on and off (LockObtainFailedException on the second constructor). With the fix it passes, TestDirectoryTaxonomyWriter passes (15 tests, 1 @Nightly skipped), the whole facet module passes (40 suites, 307 tests, 0 failures, 7 skipped), the independent reproducer's test passes 4 of 4 (both paths and the control), and the module's format, ECJ and forbidden-apis checks pass once the test is formatted. The same DirectoryTaxonomyWriter.java is on upstream main 2f424fd428d, branch_11x, branch_10_6 and 10.6.0 (read). Earlier runs: a version-portable probe on 20 releases from 3.4.0 to 10.5.2 and on main ea57360912ed (whose constructor, IndexWriter, ReaderManager and lock-factory code is identical), the hunt's and verifiers' tests on ea57360912ed, 10.5.2 and 9.12.3, and the minimal test standalone on 645d4fe14a20.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java :76 (@lucene.experimental), :136-138 (constructor javadoc: LockObtainFailedException 'if the taxonomy is locked by another writer', IOException 'if another error occurred'), :140 (constructor), :145 (openIndexWriter), :148 (assert on the merge policy), :154 (indexExists), :158 (readLatestCommit), :165 (Long.parseLong of the epoch), :185 (addCategory(new FacetLabel())), :206 (openIndexWriter javadoc: 'will be closed upon calling to close()'), :246-258 (initReaderManager, new ReaderManager at :252), :288 (close), :475 (getTaxoArrays in addCategoryDocument), :665-683 (getTaxoArrays)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java :954 (obtains write.lock), :1202-1208 (the constructor releases its write lock on any Throwable: 'init: hit exception on init; releasing write lock'); lucene/core/src/java/org/apache/lucene/store/NativeFSLockFactory.java :68 (static LOCK_HELD set), :108 (added on obtain), :146 (removed only when the lock is closed)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java:140-195 (145 openIndexWriter, 154, 158, 165, 174, 185), 215-218, 246-258 (252), 288-294, 468-475, 665-683, 861-865
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:954 (obtainLock), 1202-1208 (constructor releases its lock on any Throwable)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/store/NativeFSLockFactory.java:68, 108, 119, 132 (FileChannel.open), 145-150
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:6825 (getDocStats calls ensureOpen)
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:11568 (LUCENE-6060: Remove dangerous IndexWriter.unlock method)
Who is affected
  • a Lucene main checkout (645d4fe14a20) git grep 'new DirectoryTaxonomyWriter' lucene/demo/src/java lucene/benchmark/src/java (5 facet demo examples; CreateTaxonomyIndexTask, OpenTaxonomyIndexTask)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/store/NativeFSLockFactory.java:132 (FileChannel.open); OpenJDK 25.0.3 source java.base/sun/nio/ch/FileChannelImpl.java:153-154 (Cleaner closes the descriptor)
Minimal repro
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/store/ByteBuffersDirectory.java:106-107; SingleInstanceLockFactory.java:40
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java:76 (@lucene.experimental), 136-138 (constructor javadoc), 148 (assert), 206 (openIndexWriter javadoc)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1202-1208
  • a Lucene main checkout (645d4fe14a20) git grep 'failOn|Failure()|FilterDirectory' lucene/facet/src/test/org/apache/lucene/facet/taxonomy/: no hits
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1363-1369 (close commits when commitOnClose), 2576-2582 (rollback without ensureOpen)
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java:288-317 (close, doClose, closeResources hook javadoc), 861-865 (rollback)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/IOUtils.java:104-137 (116-136: the Error case); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1206; lucene/facet/src/java/org/apache/lucene/facet/taxonomy/writercache/LruTaxonomyWriterCache.java:84-87
  • a Lucene clone: IOUtils.closeWhileSuppressingExceptions at releases/lucene/10.4.0, 10.5.2, 10.6.0; absent at 10.3.2 and 9.12.3
History
  • a Lucene clone (fetched 2026-10-31): blob bd4934fbb04 of DirectoryTaxonomyWriter.java at 645d4fe14a20, origin/main 2f424fd428d, origin/branch_11x, origin/branch_10_6, releases/lucene/10.6.0, releases/lucene/10.5.2

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.