A full disk during a taxonomy commit makes the commit throw. The following close() throws too, and it frees nothing, on every call.
- Taxonomy facets keep category labels (for example
Author/Lisa) in a small separate index, the taxonomy.DirectoryTaxonomyWriterwrites it through an internalIndexWriterand keeps an internal near-real-time reader on that writer to look categories up. IndexWritertreats 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 throwsAlreadyClosedException.- 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."
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.- Open a
DirectoryTaxonomyWriterwith 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. - Call
commit()while the disk is full (in the test, every file creation throws anIOException). The commit throws, and the internalIndexWritercloses itself, as it does after any error while writing a new segment. - Call
close(), as try-with-resources orIOUtils.closewould. 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". - Instead
close()callscommit()again, getsAlreadyClosedException: this IndexWriter is closed, and skips its cleanup; so does every retry. The reader stays open, and the test directory reportsstill 1 open files: {_0.cfs=1}. Onlyrollback()frees it. - Second form: if the error is a failed fsync inside
close(), the internalIndexWriteralso keepswrite.lock, and a new taxonomy writer in the same JVM getsLockObtainFailedExceptionuntilclose()is retried (once the storage error has cleared) orrollback()is called.
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.
// 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)
}- The reader exists from the start. The constructor adds the root category (
DirectoryTaxonomyWriter.java:185);addCategoryDocumentcallsgetTaxoArrays()(:475), which callsinitReaderManager()(:672), which opensnew ReaderManager(indexWriter, false, false)(:252), a near-real-time reader on segment_0. The repro's stack trace shows exactly this chain. - A full disk during commit is tragic.
DirectoryTaxonomyWriter.commit()checks only its ownisClosedflag (ensureOpen,:434-438) and callsindexWriter.commit()(:527). An I/O error while flushing the new segment makes theIndexWriterrecord a tragic exception and roll itself back (IndexWriter.java:6098-6105; javadoc ofgetTragicException: "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. - Every
close()now fails before its cleanup.close()seesisClosed == falseand callscommit();ensureOpenpasses, andindexWriter.commit()throwsAlreadyClosedException("this IndexWriter is closed", tragedy)(IndexWriter.java:914-916).doClose()never runs andisClosedstays false, so the nextclose()does the same. - The leak. The reader manager's reader is not owned by the
IndexWriter, so the writer's own rollback does not close it; onlycloseResources()would. Its segment file stays open:_0.cfsin the repro. In this form theIndexWriterhas already releasedwrite.lock. - The fsync form. A
Directory.syncfailure inside the commit is not tragic: theIndexWriterstays open, so both the reader andwrite.lockstay held.NativeFSLockFactorykeeps 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. - The exit that works.
rollback()isensureOpen(); indexWriter.rollback(); doClose();(DirectoryTaxonomyWriter.java:861-865), andIndexWriter.rollback()is a no-op on a writer that already closed itself (IndexWriter.java:2576-2584), sodoClose()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 orIOUtils.close, which is how Lucene's own facet demos close the writer, instead ofrollback(). - 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 untilrollback(). With the defaultMMapDirectory(whatFSDirectory.openreturns on a 64-bit JVM) the memory mapping outlives even the writer: it survived fiveSystem.gc()calls after the writer became unreachable, as a sharedArenais freed only by an explicit close. WithNIOFSDirectorythe 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
LockObtainFailedExceptionuntil 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 (
CheckIndexclean); 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).
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.
/*
* 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();
}
}
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, sodir.close()succeeds.- Observed
close()throwsAlreadyClosedExceptionand frees nothing;dir.close()fails:there are still 1 open files: {_0.cfs=1}(assertions on and off).
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails with assertions on and off; with the recommended fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# 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 failedclose()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; onlyrollback()frees the reader. - Lucene's own facet demos close the taxonomy writer with
IOUtils.closeand 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 callrollback()afterclose()throws. The javadoc does not explicitly promise cleanup on failure either: "expected" rests on this text plus theIndexWriter.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 aclose()that keeps resources was considered a bug. - History. LUCENE-4246's trunk commit
8559eafd99e(2014-04-08) editedDirectoryTaxonomyWriter.close()(it moved theIndexWriterclose out ofdoClose()) but leftcommit()outside any try/finally. LUCENE-5987 (c064aaa4850, 2014-12-05) then madeIndexWriterclose itself on aborting exceptions. The #5316, #7049 and #6933 threads never mention the taxonomy writer. - Tragic form. Once the internal
IndexWriteris closed, no number ofclose()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 exampleSimpleFacetsExample.java:93); no non-test caller rolls back after a failed close. - Tests.
TestDirectoryTaxonomyWriterhastestRollback,testRecreateRollbackandtestEnsureOpen(:165,:182,:193), none with fault injection; no test underlucene/facet/src/testusesfailOnorMockDirectoryWrapper.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.
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.
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
Erroris thatErrorthrown instead, with the original suppressed in it,IOUtils.java:114-136). Afterwards the writer is closed, its reader and cache are closed, and theIndexWriteris rolled back (a no-op if it already closed itself), sowrite.lockis free. - After a failed final commit, uncommitted categories are discarded instead of kept for a retried
close(): the same choiceIndexWriter.close()made ("changes may have been lost"). A caller that wants to retry can callcommit()itself beforeclose(). 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.closestill runsdoClose()ifindexWriter.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
TestDirectoryTaxonomyWriterpass with assertions on and off (2 suites, 16 tests, 0 failures, 1 skipped:testHugeLabelis@Nightly); the whole:lucene:facet:testpasses with assertions on (40 suites, 307 tests, 0 failures, 7 skipped);checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestof:lucene:facetpass. - 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.
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.
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.closeusers, the facet demos included, stay exposed, and in the tragic formclose()stays unusable.
What the checks validated
Not run.
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.
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
DirectoryTaxonomyWriteradd, 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.
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().
| Where | When | How | Found |
|---|---|---|---|
| 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-01 | two 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-25 | 24 hits read, none the same bug (precedents above); refresh: 0 new items |
| Open and closed-unmerged PRs by touched file | 2026-10-26; refreshed 2026-11-01 | the 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 web | 2026-10-26 | 21 manual list queries (dev@, java-user@, issues@), the scripted search's list queries, 4 JQL queries, 4 web searches | no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 4.0.0, 4.9.1 | not affected | not 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 only | the fsync form fails; the tragic form passes (0 of 80 fault points leak on 4.10.4) |
| 5.0.0 | affected | both 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 | affected | both forms fail (release jars) |
| 10.5.2 | affected | both forms fail, assertions on and off |
10.6.0, branch_10_6, branch_11x, upstream main 2f424fd428d | by reading | the same DirectoryTaxonomyWriter.java as 645d4fe14a20 |
main 645d4fe14a20 | affected | the minimal test fails through gradle with assertions on and off; passes with the recommended fix |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Two-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, high | Corrected two overclaims, which the finder accepted: rollback() does free the tragic-form leak, and a retried close() clears the lock. |
| independent reproducer | real | Own 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, medium | Intended-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 | real | The 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, high | Re-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; confirmed | 48 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 validated | Gradle on 645d4fe14a20: the minimal test fails with assertions on and off; the recommended fix passes it, TestDirectoryTaxonomyWriter and the facet module. |
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 442 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,540 words
- 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 andclose()); with the recommended fix it passes,TestDirectoryTaxonomyWriterpasses (15 tests, 1@Nightlyskipped), 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 sameDirectoryTaxonomyWriter.javais on upstream main2f424fd428d,branch_11x,branch_10_6and the 10.6.0 and 10.5.2 tags (read). Earlier runs: the hunt's and verifiers' tests on mainea57360912ed, 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 on645d4fe14a20.
- 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-865a 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:92a 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-286a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1343-1353a 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 hita 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-137a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java:38-40 (commit the taxonomy before the main index)
- TestDirectoryTaxonomyWriterCloseAfterFailure.java:
repro/LFV-555/src/org/apache/lucene/facet/taxonomy/directory/TestDirectoryTaxonomyWriterCloseAfterFailure.java(45 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.
assets/LFV-555/bug.png(442 KB)
