All findings · Lucene finding · LFV-555 · directorytaxonomywriter-close-skips-cleanup-when-commit-fails

DirectoryTaxonomyWriter.close() skips its cleanup when its commit fails, leaving the taxonomy reader or write lock open

If a taxonomy commit fails with an I/O error such as a full disk, DirectoryTaxonomyWriter.close() throws before freeing anything: every later close() throws AlreadyClosedException, and the writer's internal reader keeps its segment file open until rollback() (with the default MMapDirectory, even after GC). Every release tested since 5.0.0 has it.

Summary

Priority
Low, rank 1083DirectoryTaxonomyWriter.close() after a failed commit (disk full or fsync) leaks NRT reader files or the write lock, plausible storage-error territory that matters to long-running processes.
Impact
minor
Since
5.0.0 (LUCENE-5987, 2014); the write-lock form since 4.10.0introduced 2014-12-05 · over 11 years
Affected
5.0.0 to 10.6.0, and main
Status
documented
Found by
a single-fault I/O sweep of the taxonomy writer, then 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

A full disk during a taxonomy commit makes the commit throw. The following close() throws too, and it frees nothing, on every call.

Background: the taxonomy writer and its IndexWriter

  1. Taxonomy facets keep category labels (for example Author/Lisa) in a small separate index, the taxonomy. DirectoryTaxonomyWriter writes it through an internal IndexWriter and keeps an internal near-real-time reader on that writer to look categories up.
  2. IndexWriter treats an I/O error while writing a new segment, such as a full disk, as tragic: it rolls itself back and closes, and every later commit or update call on it throws AlreadyClosedException.
  3. Since LUCENE-4246, IndexWriter.close() releases its files and write lock even when it throws: "If this method throws any other exception, the IndexWriter will be closed, but changes may have been lost."
Diagram of the bug: a DirectoryTaxonomyWriter holds an internal IndexWriter, a near-real-time reader on segment _0 and the write lock. commit() hits a disk-full IOException while writing a new segment, and the IndexWriter closes itself. close() calls commit() again, which throws AlreadyClosedException, so doClose() never runs and the reader keeps _0.cfs open; every retry does the same. rollback() runs doClose() and frees the reader. In the fsync form the IndexWriter stays open and the write lock stays held until close() is retried or rollback() is called.
The commit fails, close() throws before its cleanup, the internal reader (and in the fsync form the write lock) stays open, and only rollback() or, in the fsync form, a retried close() frees them. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Open a DirectoryTaxonomyWriter with the default constructor and add one category, a. The constructor's root-category add has already opened the writer's internal near-real-time reader on segment _0.
  2. Call commit() while the disk is full (in the test, every file creation throws an IOException). The commit throws, and the internal IndexWriter closes itself, as it does after any error while writing a new segment.
  3. Call close(), as try-with-resources or IOUtils.close would. You expect it to free the writer's resources even if it throws: its javadoc says it "Frees used resources as well as closes the underlying IndexWriter".
  4. Instead close() calls commit() again, gets AlreadyClosedException: this IndexWriter is closed, and skips its cleanup; so does every retry. The reader stays open, and the test directory reports still 1 open files: {_0.cfs=1}. Only rollback() frees it.
  5. Second form: if the error is a failed fsync inside close(), the internal IndexWriter also keeps write.lock, and a new taxonomy writer in the same JVM gets LockObtainFailedException until close() is retried (once the storage error has cleared) or rollback() is called.

Who is affected

Applications that use taxonomy facets with DirectoryTaxonomyWriter (default settings suffice), 5.0.0 to 10.6.0 and main, when a taxonomy commit hits an I/O error and the process keeps running and closes rather than rolls back the writer. Each failed writer leaks one reader, or holds the write lock; nothing is lost or corrupted.

Mechanism, terms and numbers548 words

DirectoryTaxonomyWriter.close() is commit(); indexWriter.close(); doClose(); with no try/finally, and doClose() is the only code that marks the writer closed, and on the close path the only code that closes its internal near-real-time reader and cache. So when the commit inside close() throws, nothing is freed. After a segment-write I/O error such as a full disk, the internal IndexWriter has closed itself, every close() throws AlreadyClosedException from its own commit(), and the reader keeps the taxonomy's segment file open until rollback(); with the default MMapDirectory its memory mapping outlives even the writer. After a failed fsync inside close(), the IndexWriter and its write lock stay open too, so a new taxonomy writer in the same JVM gets LockObtainFailedException until close() is retried or rollback() is called. Nothing is lost beyond the failed commit, nothing is corrupted, and the original exception reaches the caller. Every release run from 5.0.0 to 10.5.2 fails, and 10.6.0 and main have the same code (the write-lock form from 4.10.0). Low priority: it needs a storage error during a taxonomy commit in a process that keeps running.

Mechanism (Lucene main 645d4fe14a20)

// DirectoryTaxonomyWriter.java:288-299
public synchronized void close() throws IOException {
  if (!isClosed) {
    commit();              // :290  throws -> nothing below runs
    indexWriter.close();   // :291
    doClose();             // :292
  }
}
private void doClose() throws IOException {
  isClosed = true;         // :297  the only place close() marks the writer closed
  closeResources();        // :298  closes the reader manager and the cache (:308-318)
}
  1. The reader exists from the start. The constructor adds the root category (DirectoryTaxonomyWriter.java:185); addCategoryDocument calls getTaxoArrays() (:475), which calls initReaderManager() (:672), which opens new ReaderManager(indexWriter, false, false) (:252), a near-real-time reader on segment _0. The repro's stack trace shows exactly this chain.
  2. A full disk during commit is tragic. DirectoryTaxonomyWriter.commit() checks only its own isClosed flag (ensureOpen, :434-438) and calls indexWriter.commit() (:527). An I/O error while flushing the new segment makes the IndexWriter record a tragic exception and roll itself back (IndexWriter.java:6098-6105; javadoc of getTragicException: "closed as a side-effect of a tragic exception, e.g. disk full while flushing a new segment", :6109-6110). This self-closing came with LUCENE-5987 in 5.0.0.
  3. Every close() now fails before its cleanup. close() sees isClosed == false and calls commit(); ensureOpen passes, and indexWriter.commit() throws AlreadyClosedException("this IndexWriter is closed", tragedy) (IndexWriter.java:914-916). doClose() never runs and isClosed stays false, so the next close() does the same.
  4. The leak. The reader manager's reader is not owned by the IndexWriter, so the writer's own rollback does not close it; only closeResources() would. Its segment file stays open: _0.cfs in the repro. In this form the IndexWriter has already released write.lock.
  5. The fsync form. A Directory.sync failure inside the commit is not tragic: the IndexWriter stays open, so both the reader and write.lock stay held. NativeFSLockFactory keeps held lock paths in a static JVM-wide set (NativeFSLockFactory.java:68) that only closing the lock clears (:146), so garbage collection does not free it.
  6. The exit that works. rollback() is ensureOpen(); indexWriter.rollback(); doClose(); (DirectoryTaxonomyWriter.java:861-865), and IndexWriter.rollback() is a no-op on a writer that already closed itself (IndexWriter.java:2576-2584), so doClose() runs and everything is freed.
Exposure and likelihood304 words
  • Who. Applications that use taxonomy facets (the opt-in facet module) with DirectoryTaxonomyWriter. The default constructor, cache, IndexWriterConfig, codec and directory are enough.
  • When. An I/O error inside a taxonomy commit (a full disk, a failed file creation, a failed fsync) in a process that keeps running, followed by close(), try-with-resources or IOUtils.close, which is how Lucene's own facet demos close the writer, instead of rollback().
  • Tragic form (since 5.0.0). close() throws on every call, and one internal reader per failed writer keeps the taxonomy's segment files open until rollback(). With the default MMapDirectory (what FSDirectory.open returns on a 64-bit JVM) the memory mapping outlives even the writer: it survived five System.gc() calls after the writer became unreachable, as a shared Arena is freed only by an explicit close. With NIOFSDirectory the file handle stays open only while the writer is reachable; garbage collection then released it. If a later writer replaces the taxonomy, the deleted files' disk space stays allocated: 2 deleted files, 36,864 bytes, for a 2,000-category taxonomy in the security probe.
  • Fsync form (since 4.10.0). The taxonomy stays write-locked inside the JVM: every new taxonomy writer on it fails fast with LockObtainFailedException until the old writer is closed again or rolled back.
  • Bounded. 2 leaked inputs per injected fault in the probe (25 faults, 50 inputs); 200 close() retries or 200 blocked opens added nothing.
  • What it does not do. No lost acknowledged change and no corruption (CheckIndex clean); readers see exactly the last successful commit; ordinals stay stable; the commit's or close's exception still reaches the caller.
  • How likely. Rare: it needs a storage error during a taxonomy commit in a long-running process, and most applications restart after such an error. No public report of it has been found since 5.0.0 (see Prior reports).

Minimal repro

One JUnit test, public API plus the test framework's MockDirectoryWrapper: a default taxonomy writer adds one category, every file creation then fails, commit() and close() both throw, and the directory's own open-file check at dir.close() finds the writer's reader still open.

TestDirectoryTaxonomyWriterCloseAfterFailure.java45 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.facet.taxonomy.FacetLabel;
import org.apache.lucene.store.AlreadyClosedException;
import org.apache.lucene.tests.store.MockDirectoryWrapper;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDirectoryTaxonomyWriterCloseAfterFailure extends LuceneTestCase {

  public void testCloseAfterFailedCommitReleasesFiles() throws Exception {
    MockDirectoryWrapper dir = newMockDirectory();
    DirectoryTaxonomyWriter tw = new DirectoryTaxonomyWriter(dir);
    tw.addCategory(new FacetLabel("a"));
    dir.failOn(
        new MockDirectoryWrapper.Failure() {
          @Override
          public void eval(MockDirectoryWrapper d) throws IOException {
            if (callStackContains(MockDirectoryWrapper.class, "createOutput")) {
              throw new IOException("simulated disk full");
            }
          }
        });
    expectThrows(IOException.class, tw::commit);
    expectThrows(AlreadyClosedException.class, tw::close);
    // expected: close() released the taxonomy reader; actual: dir.close() says files are still open
    dir.close();
  }
}
Command
cp $REPRO/TestDirectoryTaxonomyWriterCloseAfterFailure.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.TestDirectoryTaxonomyWriterCloseAfterFailure -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false
Expected
close() may throw, but it frees the writer's reader, so dir.close() succeeds.
Observed
close() throws AlreadyClosedException and frees nothing; dir.close() fails: there are still 1 open files: {_0.cfs=1} (assertions on and off).

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

More files, environment, full output211 words

One test class, one method, single thread. The only subclass is the test framework's MockDirectoryWrapper.Failure, which makes every createOutput throw IOException("simulated disk full"). The outcome does not depend on the seed or the random test codec: 30 of 30 derived seeds and 4 other seeds fail the same way.

The test asserts only the two exceptions, which the buggy and the fixed code both throw; the failure is MockDirectoryWrapper's own check that every file is closed. It covers the tragic form only; the fsync form comes from the verifiers' tests (How it was found, Independent checks).

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

Environment

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

Observed output

# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (module prefixes and some frames cut; -Ptests.asserts=false prints the same)
TestDirectoryTaxonomyWriterCloseAfterFailure > testCloseAfterFailedCommitReleasesFiles FAILED
    java.lang.RuntimeException: MockDirectoryWrapper: cannot close: there are still 1 open files: {_0.cfs=1}
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:77E8C06B083C34E8]:0)
        at org.apache.lucene.tests.store.MockDirectoryWrapper.close(MockDirectoryWrapper.java:884)
        at ...TestDirectoryTaxonomyWriterCloseAfterFailure.testCloseAfterFailedCommitReleasesFiles(TestDirectoryTaxonomyWriterCloseAfterFailure.java:43)
        Caused by:
        java.lang.RuntimeException: unclosed IndexInput: _0.cfs
            at org.apache.lucene.tests.store.MockDirectoryWrapper.openInput(MockDirectoryWrapper.java:843)
            ...
            at org.apache.lucene.index.SegmentCoreReaders.<init>(SegmentCoreReaders.java:83)
            ...
            at org.apache.lucene.index.ReaderManager.<init>(ReaderManager.java:62)
            at org.apache.lucene.facet.taxonomy.directory.DirectoryTaxonomyWriter.initReaderManager(DirectoryTaxonomyWriter.java:252)
            at org.apache.lucene.facet.taxonomy.directory.DirectoryTaxonomyWriter.getTaxoArrays(DirectoryTaxonomyWriter.java:672)
            at org.apache.lucene.facet.taxonomy.directory.DirectoryTaxonomyWriter.addCategoryDocument(DirectoryTaxonomyWriter.java:475)
            ...
            at org.apache.lucene.facet.taxonomy.directory.DirectoryTaxonomyWriter.<init>(DirectoryTaxonomyWriter.java:185)
            ...
1 test completed, 1 failed

Why it looks unintended

  • close() says it "Frees used resources as well as closes the underlying IndexWriter"; IndexWriter.close() says that if it throws, "the IndexWriter will be closed".
  • After a tragic commit no close() can succeed, so retrying cannot help; only rollback() frees the reader.
  • Lucene's own facet demos close the taxonomy writer with IOUtils.close and never roll back.
The full argument253 words
  • The contract text. close(): "Frees used resources as well as closes the underlying IndexWriter, which commits whatever changes made to it to the underlying Directory" (DirectoryTaxonomyWriter.java:284-285). openIndexWriter: "the instance this method returns will be closed upon calling to close()" (:206). Neither excludes the failure path, and nothing documents that the caller must call rollback() after close() throws. The javadoc does not explicitly promise cleanup on failure either: "expected" rests on this text plus the IndexWriter.close() precedent.
  • The delegate's contract. IndexWriter.close(): "If this method throws any other exception, the IndexWriter will be closed, but changes may have been lost" (IndexWriter.java:1351-1352). LUCENE-4246 (#5316) introduced this because a close() that keeps resources was considered a bug.
  • History. LUCENE-4246's trunk commit 8559eafd99e (2014-04-08) edited DirectoryTaxonomyWriter.close() (it moved the IndexWriter close out of doClose()) but left commit() outside any try/finally. LUCENE-5987 (c064aaa4850, 2014-12-05) then made IndexWriter close itself on aborting exceptions. The #5316, #7049 and #6933 threads never mention the taxonomy writer.
  • Tragic form. Once the internal IndexWriter is closed, no number of close() calls can succeed, so even the old "retry close()" contract does not cover it.
  • Callers. Five facet demos close with IOUtils.close(indexWriter, taxoWriter) (for example SimpleFacetsExample.java:93); no non-test caller rolls back after a failed close.
  • Tests. TestDirectoryTaxonomyWriter has testRollback, testRecreateRollback and testEnsureOpen (:165, :182, :193), none with fault injection; no test under lucene/facet/src/test uses failOn or MockDirectoryWrapper.Failure, so no test asserts or tolerates the current behaviour.
The devil's-advocate check170 words

The strongest "works as designed" case (the devil's-advocate verifier; the cross-model intended lens rated it moderate): close() follows the old, pre-LUCENE-4246 IndexWriter contract, in which a close() that throws did not happen, the writer stays usable, and the caller retries close() or calls rollback(), which keeps uncommitted categories for a retry. AutoCloseable.close() says "@throws Exception if this resource cannot be closed"; TwoPhaseCommitTool rolls back on failure; the class is @lucene.experimental (DirectoryTaxonomyWriter.java:76); and in the fsync form a retried close() does recover.

Why it fails: LUCENE-4246 rejected exactly that contract for IndexWriter; it was never documented for DirectoryTaxonomyWriter; it cannot cover the tragic form, where close() never succeeds; the JDK's AutoCloseable apiNote itself advises releasing resources before throwing because close "is unlikely to be invoked more than once"; and Lucene's own demos rely on a single close(). Keeping uncommitted categories is the trade-off IndexWriter.close() already gave up ("changes may have been lost"). Verdict: a defect of Low priority; the tragic form is the stronger case.

Suggested fix

Recommended A. Roll back and clean up when the final commit fails

In close(), if the final commit() throws, roll the internal IndexWriter back and run the cleanup anyway, suppressing secondary errors into the original exception; otherwise close the IndexWriter and run the cleanup through IOUtils.close. The repro passes with assertions on and off, and so do the taxonomy writer's tests and the facet module.

All options and trade-offs3 options · 492 words

Option A was run through gradle in a scratch worktree of 645d4fe14a20; B and C were not run. All three change only close()'s failure path: a successful close() still commits and closes as before.

A. Roll back and clean up when the final commit fails (validated by the checks) (recommended)

In DirectoryTaxonomyWriter.close() (DirectoryTaxonomyWriter.java:288-294), -3/+8 plus an IOUtils import.

   public synchronized void close() throws IOException {
     if (!isClosed) {
-      commit();
-      indexWriter.close();
-      doClose();
+      try {
+        commit();
+      } catch (Throwable t) {
+        // still free everything, as IndexWriter.close() does when it fails
+        IOUtils.closeWhileSuppressingExceptions(t, indexWriter::rollback, this::doClose);
+        throw t;
+      }
+      IOUtils.close(indexWriter, this::doClose);
     }
   }

Trade-offs

  • The caller still gets the original exception, with any secondary failure suppressed in it (only if a cleanup step throws an Error is that Error thrown instead, with the original suppressed in it, IOUtils.java:114-136). Afterwards the writer is closed, its reader and cache are closed, and the IndexWriter is rolled back (a no-op if it already closed itself), so write.lock is free.
  • After a failed final commit, uncommitted categories are discarded instead of kept for a retried close(): the same choice IndexWriter.close() made ("changes may have been lost"). A caller that wants to retry can call commit() itself before close(). Main-index documents that already use those ordinals must then not be committed either: the taxonomy "must always be committed to disk before the main index" (TaxonomyWriter.java:38-40).
  • On success, IOUtils.close still runs doClose() if indexWriter.close() throws.

What the checks validated

  • Stock 645d4fe14a20 (gradle, scratch worktree, seed DEADBEEF): the repro fails with assertions on and off.
  • With A: the repro and TestDirectoryTaxonomyWriter pass with assertions on and off (2 suites, 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); checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest of :lucene:facet pass.
  • Earlier, standalone: the finder's, the independent reproducer's and the minimal tests pass with it, assertions on and off. A root-cause check, not a reviewed upstream patch.

B. Only guard the cleanup with a finally (not run)

try { commit(); } finally { IOUtils.close(indexWriter, this::doClose); }: smaller, and it frees the reader and, through IndexWriter.close(), which closes even when it throws, the lock.

Trade-offs

  • On a failed commit, IndexWriter.close() (commit-on-close by default) flushes and commits again (IndexWriter.java:1312-1339), so after a transient error changes could land after the caller already saw an exception, and it does a second round of I/O on a failing disk.

What the checks validated

Not run.

C. Document that a failed close() needs rollback() (not run)

State in the close() javadoc that after close() throws, the caller must call rollback().

Trade-offs

  • No code change, but try-with-resources and IOUtils.close users, the facet demos included, stay exposed, and in the tragic form close() stays unusable.

What the checks validated

Not run.

Downstream note

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

How it was found

A bounded single-fault sweep: each taxonomy-writer operation ran once per possible directory I/O event, with one IOException injected at exactly that event, and then checked that no file stayed open and the write lock could be taken again. The add, commit and close scenarios flagged leaks; reading close() explained them, and a JUnit test confirmed it.

The method in full141 words

Method: semi-formal (a bounded-exhaustive single-fault enumeration on the real jar). The sweep runs each public operation once per MockDirectoryWrapper failure event, injects a one-shot IOException at exactly that event, and then checks three properties: every write lock can be obtained again, no file is left open, and the injected exception reaches the caller.

  • Its two DirectoryTaxonomyWriter add, commit and close scenarios (empty and existing taxonomy, 8,307 runs) gave file-leak and lock-leak outcomes; the buckets count runs, not distinct bugs, and also hold a separate constructor bug.
  • The finder root-caused them by reading close() and reduced them to a public-API JUnit test with two methods (the tragic form and the fsync form); the minimal repro keeps the tragic form only.
  • The same unit's bytecode exception-edge scan does not model close() of a resource held in a field, and did not flag this.

Prior reports, history and checks

Prior reports

No prior report: issues and PRs, the JIRA archive, the dev@, java-user@ and issues@ lists, downstream projects and the web (158 queries, 2026-10-26; refreshed 2026-11-01). Precedents only: LUCENE-4246 (#5316) and LUCENE-5987 (#7049) for IndexWriter, and LUCENE-8244 (#9291), a fixed taxonomy-manager leak.

Searches3 searches

Verdict new. The closest items are precedents, not reports of this bug: #5316 / LUCENE-4246 made IndexWriter.close() close even on exception (its commit edited the taxonomy writer's close() only mechanically); #5825 / LUCENE-4760 (open) is the IndexWriter version of the write-lock symptom; #9291 / LUCENE-8244 fixed a SearcherTaxonomyManager exception-path leak; #4653 / LUCENE-3579 added ensureOpen after close. Upstream main has the same close().

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene and the old lucene-solr, solr, lucenenet, Elasticsearch, OpenSearch; all of GitHub)2026-10-26; refreshed 2026-11-01two runs of a scripted prior-report search plus 55 GraphQL searches: the class name with close, exception, leak, rollback, lock, commit, write.lock, tragic and AlreadyClosedException, and the exact error messages; refresh: DirectoryTaxonomyWriter and TaxonomyWriter updated since 2026-10-2524 hits read, none the same bug (precedents above); refresh: 0 new items
Open and closed-unmerged PRs by touched file2026-10-26; refreshed 2026-11-01the changed files of all 407 open and 617 closed-unmerged PRs; refresh: the 60 most recently updated open PRs#12862, #13951, #16847 (open) and #220, #420 (closed) touch the file, none close(); the refreshed PRs do not touch it
Mailing lists, JIRA archive and the web2026-10-2621 manual list queries (dev@, java-user@, issues@), the scripted search's list queries, 4 JQL queries, 4 web searchesno report

History

The close() code dates from 2012, but it became a bug when IndexWriter changed under it: LUCENE-5987 (2014-12-05) made IndexWriter close itself on segment-write errors, so the tragic form starts in 5.0.0 (4.10.4 is clean); the write-lock form starts in 4.10.0. Every release executed from 5.0.0 to 10.5.2 fails.

Versions tested7 rows

No single taxonomy-writer commit introduced it. close() has committed before any cleanup, with no try/finally, since 242092c929d (LUCENE-3794, 2012-02-16; a conditional form since 0780806efe6, LUCENE-3573, 2011). The tragic form needs LUCENE-5987 (c064aaa4850 on branch_5x, de4c6513bcb on trunk, 2014-12-05, "IndexWriter forcefully closes itself on hitting aborting exception"). The write-lock form became inconsistent with IndexWriter.close() at 2cfcdccdd29 (2014-08-14, "IW.close really closes the IndexWriter, even on exception", the 4.x port of LUCENE-4246); before that, IndexWriter.close() also kept the lock after a failed close, as its javadoc then said. No PR (svn era).

Executed: 48 history runs on 14 targets from 4.0.0 to main; the IndexWriter controls flip at the same boundaries. A single-fault sweep of commit() found 0 of 80 leaking fault points on 4.10.4 and 40 of 74 on 5.0.0, up to 94 of 115 on main.

Versions and branches executed, with results
Version or commitVerdictResult
4.0.0, 4.9.1 not affectednot counted: IndexWriter.close() itself kept the lock after a failed close then, as documented; no tragic form
4.10.0, 4.10.4 write-lock form onlythe fsync form fails; the tragic form passes (0 of 80 fault points leak on 4.10.4)
5.0.0 affectedboth forms fail, assertions on and off (40 of 74 fault points leak)
5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3 affectedboth forms fail (release jars)
10.5.2 affectedboth forms fail, assertions on and off
10.6.0, branch_10_6, branch_11x, upstream main 2f424fd428d by readingthe same DirectoryTaxonomyWriter.java as 645d4fe14a20
main 645d4fe14a20 affectedthe minimal test fails through gradle with assertions on and off; passes with the recommended fix

Independent checks

Confirmed with high confidence: the finder, an independent reproducer, a devil's-advocate verifier and an adjudication each reproduced it, and the fix passed every time it was run (all but the devil's-advocate verifier ran it). Reviewers from another model family, unable to run code, rated it real; one said "not real" only because it could not run code.

Checks and verdicts342 words

Everyone agrees on the root cause; the only disagreement is how strong the "retry or roll back" design argument is for the fsync form.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateTwo-test JUnit fails on main (assertions on and off), 10.5.2 and 9.12.3: {_0.cfs=1} open after the failed commit and close, and the taxonomy write-locked after a failed fsync in close(); the fix passes both; TestDirectoryTaxonomyWriter passes with it.
review (OpenAI GPT-5.6, read-only) real, highCorrected two overclaims, which the finder accepted: rollback() does free the tragic-form leak, and a retried close() clears the lock.
independent reproducer realOwn 3-test JUnit fails 2 of 3 on main (assertions on and off), 10.5.2 and 9.12.3, on 40 of 40 seeds; the rollback() control passes; its own close()-only fix passes all 3.
cross-model review, intended lens (OpenAI GPT-5.6, read-only) real, mediumIntended-behaviour argument moderate; root cause agrees; inputs in contract. A separate run re-ran the finder's test: fails; with the fix: passes.
cross-model review, refute lens (OpenAI GPT-5.6, read-only) not real, low (procedural)Could not write or run its own test, so its verdict is procedural; most of its evidence items support "real".
devil's-advocate verifier realThe old retry contract is the strongest "intended" case and fails for the tragic form; its probe showed the lock surviving try-with-resources plus System.gc(), and rollback() freeing everything.
adjudication confirmed, highRe-ran the reproducer's test with a new seed (2 of 3 fail, the control passes) and with the fix (3 of 3 pass). Class minor.
history, security screen, audit, minimization 5.0.0 first affected; security none; confirmed48 history runs; a security probe on main, 10.5.2 and 9.12.3 (bounded, fail-closed leak); a credibility re-run with a fresh seed; the one-test repro on 645d4fe14a20 (30 of 30 seeds fail; the fix passes).
this doc (2026-11-01) reproduced; fix validatedGradle on 645d4fe14a20: the minimal test fails with assertions on and off; the recommended fix passes it, TestDirectoryTaxonomyWriter and the facet module.

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

Finding record

LFV-555, snapshot of 2026-11-06

Registry title
DirectoryTaxonomyWriter.close() skips its own cleanup when its commit fails: after an I/O error that closes the internal IndexWriter every close() throws and the writer's near-real-time reader keeps its segment files open, and after an fsync error inside close() the IndexWriter and the taxonomy write lock stay held until close() is retried or rollback() is called (every release since 5.0.0)
Area
facet / taxonomy writer: DirectoryTaxonomyWriter.close() (failure cleanup)
Priority
Low, rank 1083: DirectoryTaxonomyWriter.close() after a failed commit (disk full or fsync) leaks NRT reader files or the write lock, plausible storage-error territory that matters to long-running processes.
Impact class
minor
Impact note
Applications of taxonomy facets (the default DirectoryTaxonomyWriter constructor and cache, IndexWriterConfig, codec and directory suffice, with -ea and -da) whose taxonomy commit hits an I/O error and that then close the writer with close(), try-with-resources or IOUtils.close (as Lucene's own facet demos do) instead of rollback() are hit: after a segment-write error such as a full disk, which closes the internal IndexWriter, every close() throws AlreadyClosedException and the writer's near-real-time reader keeps its segment files open (one reader per failed writer, bounded by the taxonomy's size; files deleted later stay allocated) until rollback() is called or the JVM exits, and after an fsync error in close()'s own commit the internal IndexWriter and its write lock stay open, so a new taxonomy writer in the same JVM gets LockObtainFailedException until close() is retried or rollback() is called; nothing is lost or corrupted, readers see the last successful commit, the original exception still reaches the caller, and no scale is needed.
Summary
You add categories to a DirectoryTaxonomyWriter, a commit fails with an I/O error (for example a full disk while the new segment is written), and you then call close(), whose javadoc says it 'Frees used resources as well as closes the underlying IndexWriter'. You expect close() to release the writer's resources even when it throws, as IndexWriter.close() has done since LUCENE-4246. Instead close() is 'commit(); indexWriter.close(); doClose();' with no try/finally, so it throws AlreadyClosedException from its own commit() on every call and never closes its internal near-real-time reader, whose segment file stays open; when the error is an fsync inside close(), the internal IndexWriter and the taxonomy's write lock also stay held until close() is retried or rollback() is called.
Introduced
No single commit [read: git log -L on close() and git blame -w -M -C in the history clone; boundaries exec]. The DirectoryTaxonomyWriter shape (close() commits before any cleanup, with no try/finally) dates from 242092c929d (branch_3x 0c300ae6113), LUCENE-3794 (= #4867), 2012-02-16, with a conditional form since 0780806efe6 (LUCENE-3573, 2011-11-16). It became this bug when IndexWriter changed underneath it: c064aaa4850 (branch_5x; trunk de4c6513bcb), LUCENE-5987 (= #7049) 'IndexWriter forcefully closes itself on hitting aborting exception', enables the lead (tragic-commit) variant; 2cfcdccdd29 (branch_4x, 'IW.close really closes the IndexWriter, even on exception', labelled LUCENE-5871 = #6933, the 4.x port of LUCENE-4246 = #5316; trunk 8559eafd99e, which also edited DTW.close() mechanically and left commit() outside a try/finally) is where the write-lock variant starts to diverge from IndexWriter.close()'s own contract. No PR (svn era); the #5316, #7049 and #6933 threads never mention the taxonomy writer or the facet module (2014-12-05 (LUCENE-5987: lead variant); 2014-08-14 (2cfcdccdd29: write-lock variant); the DirectoryTaxonomyWriter code shape 2012-02-16 (LUCENE-3794))
First affected version
5.0.0 for the lead (tragic-commit) variant [exec; 4.10.4 is not affected: 0 of 80 fault points of commit() leak]; 4.10.0 for the secondary write-lock variant, counted from when IndexWriter.close() itself started to always close [exec; on 4.0.0 and 4.9.1 IndexWriter.close() also kept the lock, as documented then]
Affected versions
Both variants on every release from 5.0.0 to 10.6.0 (10.6.0 by reading) and on main ea57360912ed [exec: history matrix on 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.5.2 and main, -da also on 5.0.0, 10.5.2 and main; fault-point sweep 40/74 leaking points on 5.0.0 up to 94/115 on main; the unchanged finder JUnit fails on 9.1.0, 9.12.3, 10.5.2 and main; the independent reproduction's JUnit fails on 9.12.3 and 10.5.2]; close() is 'commit(); indexWriter.close(); doClose();' at every release tag from 5.0.0 on [read: 5.0.0, 5.5.5, 6.0.0, 7.0.0, 8.0.0, 9.12.3, 10.5.2]. 4.10.0-4.10.4: write-lock variant only [exec 4.10.0, 4.10.4]. 3.5.0-4.9.1: not counted (the lock is kept after a failed close() commit, as IndexWriter.close() then documented; the lead variant does not occur) [exec 4.0.0, 4.9.1; 3.x by reading]. Upstream main a9779ef3c19 (2026-10-26), branch_10x 1309534296e and branch_10_6 2763db11b0a have the same close() [read]. Not executed: OutOfMemoryError paths, and the 4.10.1-4.10.4 tragic-event paths beyond file-level faults. Executed only on aarch64 Linux with JDK 25
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (searched 2026-10-26, read-only): 158 query records, among them two scripted prior-report searches (69 queries over GitHub issues, PRs and commits, Elasticsearch/OpenSearch/Solr, the dev@, java-user@ and issues@ lists, the JIRA archive and the web) and the finder's earlier 37-query run, 55 GitHub GraphQL searches (apache/lucene with the class name and close, exception, leak, rollback, lock, commit, write.lock, tragic and AlreadyClosedException, and the exact error messages; apache/lucene-solr, apache/solr, apache/lucenenet, elastic/elasticsearch, opensearch-project/OpenSearch; all of GitHub), 21 manual list queries, 4 JQL queries and 4 web searches; 24 hits read, none the same bug. Not fixed upstream: close() is unchanged on upstream main (2026-10-26); the last commit to DirectoryTaxonomyWriter.java is f339e24e8ed6 (2024-02-08, #12337), and none of the 90 commits that touched the file changes close() failure handling; of the 407 open PRs only #12862, #13951 and #16847 touch the file and none touches close(); of the 617 closed-unmerged PRs only #220 and #420 touch it (doc-values work). Related precedents, not reports of this bug: #5316 / LUCENE-4246 (made IndexWriter.close() close even on exception; its commit touched DTW.close() only mechanically), #5825 / LUCENE-4760 (open; the IndexWriter version of the write-lock symptom), #9291 / LUCENE-8244 (a SearcherTaxonomyManager exception-path leak, fixed), #4653 / LUCENE-3579 (ensureOpen after close), #7669 / LUCENE-6611 and #7720 / LUCENE-6662 (a different leak site in DirectoryTaxonomyWriter), #6415 / LUCENE-5351 and #16625 / #16643 (other classes).
Lucene commit tested
main 645d4fe14a20 (2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on and off ({_0.cfs=1} still open after the failed commit and close()); with the recommended fix it passes, TestDirectoryTaxonomyWriter passes (15 tests, 1 @Nightly skipped), the whole facet module passes (40 suites, 307 tests, 0 failures, 7 skipped) and the module's format, ECJ and forbidden-apis checks pass. The same DirectoryTaxonomyWriter.java is on upstream main 2f424fd428d, branch_11x, branch_10_6 and the 10.6.0 and 10.5.2 tags (read). Earlier runs: the hunt's and verifiers' tests on main ea57360912ed, 10.5.2 and 9.12.3, a history matrix of 48 runs from 4.0.0 to main, a security probe, 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 (at ea57360912ed) (@lucene.experimental), :206 (openIndexWriter javadoc: 'the instance this method returns will be closed upon calling to close()'), :246-258 (initReaderManager: NRT ReaderManager on the internal IndexWriter), :283-294 (close() javadoc 'Frees used resources as well as closes the underlying IndexWriter' and the body 'if (!isClosed) { commit(); indexWriter.close(); doClose(); }'), :296-299 (doClose: isClosed = true; closeResources()), :308-318 (closeResources: ReaderManager and cache), :434-438 (ensureOpen checks only isClosed), :511-527 (commit(): ensureOpen(), then the internal IndexWriter's commit), :452 and :665 (addCategoryDocument calls getTaxoArrays, which opens the reader manager), :855-865 (rollback() javadoc and body: ensureOpen(); indexWriter.rollback(); doClose())
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1351 (at ea57360912ed) (close() javadoc: 'If this method throws any other exception, the IndexWriter will be closed, but changes may have been lost'); lucene/core/src/java/org/apache/lucene/store/NativeFSLockFactory.java:68, :146 (JVM-wide held-lock set, cleared only by closing the lock); lucene/demo/src/java/org/apache/lucene/demo/facet/SimpleFacetsExample.java:93 (IOUtils.close(indexWriter, taxoWriter))
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java:185, 246-258 (252), 283-299, 308-318, 434-438, 475, 511-527 (527), 640-662 (658: perhapsFillCache also closes the ReaderManager once the cache is complete), 665-683 (672), 855-865
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:908-916 (ensureOpen), 1343-1353 (close javadoc), 2576-2584 (rollback), 6096-6112 (maybeCloseOnTragicEvent, getTragicException javadoc)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/store/NativeFSLockFactory.java:68, 108, 146
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/demo/src/java/org/apache/lucene/demo/facet/SimpleFacetsExample.java:93 (IOUtils.close(indexWriter, taxoWriter)); also ExpressionAggregationFacetsExample.java:82, MultiCategoryListsFacetsExample.java:92, SandboxFacetsExample.java:136, AssociationsFacetsExample.java:92
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:155-161 (open: MMapDirectory on 64-bit), MMapDirectory.java:407, RefCountedSharedArena.java:74 (Arena.ofShared)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java:76, 206, 283-286
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1343-1353
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/test/org/apache/lucene/facet/taxonomy/directory/TestDirectoryTaxonomyWriter.java:165, 182, 193; grep -rln 'failOn|MockDirectoryWrapper.Failure' lucene/facet/src/test: no hit
  • a Lucene main checkout (645d4fe14a20) lucene/demo/src/java/org/apache/lucene/demo/facet/SimpleFacetsExample.java:93
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1312-1339 (shutdown: flush and commit on close), 2576-2584 (rollback on a closed writer)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/IOUtils.java:71, 104-137
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java:38-40 (commit the taxonomy before the main index)

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.