The constructor takes the write lock first, then does more I/O, and has no cleanup if that I/O fails.
- Taxonomy facets keep their category labels in a separate Lucene index, the taxonomy.
DirectoryTaxonomyWriterwrites it through an internalIndexWriterthat it creates in its constructor and closes in its ownclose(). - An
IndexWriterholds the directory'swrite.lockuntil it is closed or rolled back. WithFSDirectory's defaultNativeFSLockFactory, 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 withLockObtainFailedException. - In Java, when a constructor throws, the caller receives no object. Only the constructor itself can release what it already acquired.
- Open an existing taxonomy with
new DirectoryTaxonomyWriter(dir). The constructor first opens an internalIndexWriter, which takes the directory'swrite.lock, then reads the latest commit to get the taxonomy's epoch. - That read fails once: one
IOExceptionwhile openingsegments_N(injected in the repro; in practice, say, "Too many open files"). The constructor throws it, as it should. - 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. - Instead the retry, and all 200 retries in a probe, fail with
LockObtainFailedException("Lock held by this virtual machine" onFSDirectory). 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. - 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.
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.
// 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 :145openIndexWriter(:215-218) returnsnew IndexWriter(directory, config), which obtainswrite.lock(IndexWriter.java:954). IfIndexWriter's own constructor fails, it releases the lock itself (IndexWriter.java:1202-1208, "init: hit exception on init; releasing write lock").- Path A, existing taxonomy:
SegmentInfos.readLatestCommit(dir)(:158) readssegments_Nagain, under the lock, for theindex.epochentry. AnIOExceptionthere propagates. - Path B, new taxonomy:
addCategory(new FacetLabel())(:185) adds the root document (:468), thengetTaxoArrays()(:475,:665-683) callsinitReaderManager(), which opens a near-real-time reader (new ReaderManager(indexWriter, false, false),:252). AnIOExceptionwhile that reader opens a segment file propagates; a reader manager already built leaks too. - The exception leaves the constructor. The only releases,
close()(:288-294) androllback()(:861-865), are instance methods of an object the caller never got. NativeFSLockFactoryadds the lock path to its staticLOCK_HELDset 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 fiveSystem.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 andFSDirectorysuffice. 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
LockObtainFailedExceptionuntil that JVM exits; 200 retries all failed. Another process is locked out only until that JVM's garbage collector closes the leaked lock channel:NativeFSLockFactoryopens the lock withFileChannel.open(NativeFSLockFactory.java:132), and OpenJDK 25 registers aCleanerthat closes an unreachable channel's descriptor. A cross-process probe showed it: another JVM was refused ("Lock held by another program"), then after oneSystem.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-defaultSimpleFSLockFactory,write.locksurvives 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.
IndexWritertreats 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
IndexWriterand its lock descriptor, until garbage collection reclaims them; retries fail before they acquire anything.
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.
/*
* 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);
}
}
}
}
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).
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails with assertions on and off; with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (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)]- The javadoc documents
LockObtainFailedExceptiononly "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). TheopenIndexWriterjavadoc makes the taxonomy writer the owner of theIndexWriter: "the instance this method returns will be closed upon calling to close()" (:206); a failed constructor leaves no object to callclose()on. - The codebase convention.
IndexWriter's constructor catches anyThrowableand releases its lock (IndexWriter.java:1202-1208), added after the same symptom inIndexWriter(LUCENE-2476, #3550). In the same module, LUCENE-8244 added a failure-path close toSearcherTaxonomyManager. - 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
:148assert; the later I/O steps never got the cleanup. - History. LUCENE-4532 (#5598, 2012) moved the commit read after
openIndexWriterso 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).
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
Errorwhile the original is not one is thatErrorthrown instead, with the original suppressed into it (IOUtils.java:116-136), as inIndexWriter's own constructor (IndexWriter.java:1206). The cache is not closed: the defaultLruTaxonomyWriterCacheis heap-only (itsclose()only clears a map), and a cache the caller passed in stays with the caller. - A subclass that overrides
openIndexWriterto supply its ownIndexWritergets that writer rolled back when the constructor fails. TheopenIndexWriterjavadoc 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.closeWhileHandlingExceptionin 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
TestDirectoryTaxonomyWriterpass with assertions on and off (16 tests, 0 failures, 1 skipped:testHugeLabelis@Nightly); the whole:lucene:facet:testpasses 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
ByteBuffersDirectoryand onFSDirectory, 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,forbiddenApisMainandforbiddenApisTestpass;checkGoogleJavaFormatpasses for the fixed class and, once./gradlew tidyrewraps two lines of the repro test (line breaks only), for the test too.
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.
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.
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
IndexWriterfromopenIndexWriter(:145) is still held on the exception edges ofDirectoryReader.indexExists(:154),SegmentInfos.readLatestCommit(:158) andaddCategory(: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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, apache/lucene-solr, apache/lucenenet, downstream servers, all of GitHub) | 2026-10-26; refreshed 2026-11-01 | two 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 file | 2026-10-26; refreshed 2026-11-01 | the 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 JIRA | 2026-10-26 | dev@, java-user@, issues@, commits@ and builds@ (direct and scripted searches; CI failure mails read); full comments of 8 JIRA issues | the 2015 dev@ Coverity thread (only the nextID update); CI failures of other tests; no report |
| Web and git history | 2026-10-26 | 10 web searches; git log --follow over the file (108 commits, 2011-2024) | no report; no commit ever changed cleanup in the constructor |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 3.3 | not affected | no facet module, the API does not exist |
| 3.4.0, 3.5.0, 3.6.2, 4.0.0 | partly | path B (new taxonomy) leaks; path A does not (no read after the writer opens, or the read comes before it) |
| 4.1.0 | affected | both 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 | affected | both paths leak (release jars) |
| 9.12.3, 10.5.2 | affected | both 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 reading | the same file as 645d4fe14a20 |
main 645d4fe14a20 | affected | the minimal test fails through gradle with assertions on and off; passes with the fix |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Static 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, high | Lowered 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 | real | Own 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, high | Intended-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, high | Re-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; verified | 20 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 adopted | Re-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 validated | Gradle 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. |
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 402 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,757 words
- 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 (LockObtainFailedExceptionon the second constructor). With the fix it passes,TestDirectoryTaxonomyWriterpasses (15 tests, 1@Nightlyskipped), 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 sameDirectoryTaxonomyWriter.javais on upstream main2f424fd428d,branch_11x,branch_10_6and 10.6.0 (read). Earlier runs: a version-portable probe on 20 releases from 3.4.0 to 10.5.2 and on mainea57360912ed(whose constructor,IndexWriter,ReaderManagerand lock-factory code is identical), the hunt's and verifiers' tests onea57360912ed, 10.5.2 and 9.12.3, and the minimal test standalone on645d4fe14a20.
- 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-865a 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-150a 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-1208a 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-87a 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
- TestDirectoryTaxonomyWriterCtorFailure.java:
repro/LFV-558/src/org/apache/lucene/facet/taxonomy/directory/TestDirectoryTaxonomyWriterCtorFailure.java(59 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.
assets/LFV-558/bug.png(402 KB)
